1
0

feat: 完成前端重构,采用 Ant Design 5 和完整测试体系

- 采用 Ant Design 5 作为 UI 组件库,替换自定义组件
- 集成 React Router v7 提供路由导航
- 使用 TanStack Query v5 管理数据获取和缓存
- 建立 Vitest + React Testing Library 测试体系
- 添加 Playwright E2E 测试覆盖
- 使用 MSW mock API 响应
- 配置 TypeScript strict 模式
- 采用 SCSS Modules 组织样式
- 更新 OpenSpec 规格以反映前端架构变更
- 归档 frontend-refactor 变更记录
This commit is contained in:
2026-04-16 11:21:48 +08:00
parent c17903dcbc
commit 9359ca7f62
61 changed files with 4588 additions and 1095 deletions

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { request, fromApi, toApi } from '@/api/client';
import { ApiError } from '@/types';
describe('fromApi', () => {
it('converts snake_case keys to camelCase', () => {
const input = { first_name: 'John', last_name: 'Doe' };
const result = fromApi<{ firstName: string; lastName: string }>(input);
expect(result).toEqual({ firstName: 'John', lastName: 'Doe' });
});
it('converts nested objects recursively', () => {
const input = {
user_name: 'alice',
contact_info: { email_address: 'alice@example.com' },
};
const result = fromApi<{
userName: string;
contactInfo: { emailAddress: string };
}>(input);
expect(result).toEqual({
userName: 'alice',
contactInfo: { emailAddress: 'alice@example.com' },
});
});
it('converts arrays recursively', () => {
const input = [
{ item_name: 'a' },
{ item_name: 'b' },
];
const result = fromApi<Array<{ itemName: string }>>(input);
expect(result).toEqual([{ itemName: 'a' }, { itemName: 'b' }]);
});
it('returns primitives unchanged', () => {
expect(fromApi<string>('hello')).toBe('hello');
expect(fromApi<number>(42)).toBe(42);
expect(fromApi<null>(null)).toBeNull();
});
});
describe('toApi', () => {
it('converts camelCase keys to snake_case', () => {
const input = { firstName: 'John', lastName: 'Doe' };
const result = toApi<{ first_name: string; last_name: string }>(input);
expect(result).toEqual({ first_name: 'John', last_name: 'Doe' });
});
it('converts nested objects recursively', () => {
const input = {
userName: 'alice',
contactInfo: { emailAddress: 'alice@example.com' },
};
const result = toApi<{
user_name: string;
contact_info: { email_address: string };
}>(input);
expect(result).toEqual({
user_name: 'alice',
contact_info: { email_address: 'alice@example.com' },
});
});
it('converts arrays recursively', () => {
const input = [{ itemName: 'a' }, { itemName: 'b' }];
const result = toApi<Array<{ item_name: string }>>(input);
expect(result).toEqual([{ item_name: 'a' }, { item_name: 'b' }]);
});
it('returns primitives unchanged', () => {
expect(toApi<string>('hello')).toBe('hello');
expect(toApi<number>(42)).toBe(42);
expect(toApi<null>(null)).toBeNull();
});
});
describe('request', () => {
const mswServer = setupServer();
beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' }));
afterEach(() => mswServer.resetHandlers());
afterAll(() => mswServer.close());
it('parses JSON and converts snake_case keys to camelCase on success', async () => {
mswServer.use(
http.get('/api/test', () => {
return HttpResponse.json({
id: '1',
created_at: '2025-01-01',
nested_obj: { inner_key: 'value' },
});
}),
);
const result = await request<{
id: string;
createdAt: string;
nestedObj: { innerKey: string };
}>('GET', '/api/test');
expect(result).toEqual({
id: '1',
createdAt: '2025-01-01',
nestedObj: { innerKey: 'value' },
});
});
it('throws ApiError with status and message on HTTP error', async () => {
mswServer.use(
http.get('/api/test', () => {
return HttpResponse.json(
{ message: 'Not found' },
{ status: 404 },
);
}),
);
await expect(request('GET', '/api/test')).rejects.toThrow(ApiError);
try {
await request('GET', '/api/test');
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
const apiError = error as ApiError;
expect(apiError.status).toBe(404);
expect(apiError.message).toBe('Not found');
}
});
it('throws ApiError with default message when error body has no message', async () => {
mswServer.use(
http.get('/api/test', () => {
return HttpResponse.json(
{ error: 'something' },
{ status: 500 },
);
}),
);
try {
await request('GET', '/api/test');
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
const apiError = error as ApiError;
expect(apiError.status).toBe(500);
expect(apiError.message).toContain('500');
}
});
it('throws Error on network failure', async () => {
mswServer.use(
http.get('/api/test', () => {
return HttpResponse.error();
}),
);
await expect(request('GET', '/api/test')).rejects.toThrow();
});
it('returns undefined for 204 No Content', async () => {
mswServer.use(
http.delete('/api/test/1', () => {
return new HttpResponse(null, { status: 204 });
}),
);
const result = await request('DELETE', '/api/test/1');
expect(result).toBeUndefined();
});
it('sends body with camelCase keys converted to snake_case', async () => {
let receivedBody: Record<string, unknown> | null = null;
mswServer.use(
http.post('/api/test', async ({ request }) => {
receivedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ id: '1' });
}),
);
await request('POST', '/api/test', {
providerId: 'prov-1',
modelName: 'gpt-4',
});
expect(receivedBody).toEqual({
provider_id: 'prov-1',
model_name: 'gpt-4',
});
});
it('sends Content-Type header as application/json', async () => {
let contentType: string | null = null;
mswServer.use(
http.post('/api/test', async ({ request }) => {
contentType = request.headers.get('Content-Type');
return HttpResponse.json({ id: '1' });
}),
);
await request('POST', '/api/test', { name: 'test' });
expect(contentType).toBe('application/json');
});
});

View File

@@ -0,0 +1,166 @@
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { listModels, createModel, updateModel, deleteModel } from '@/api/models';
const mockModels = [
{
id: 'gpt-4',
provider_id: 'prov-1',
model_name: 'gpt-4',
enabled: true,
created_at: '2025-01-01T00:00:00Z',
},
{
id: 'claude-3',
provider_id: 'prov-2',
model_name: 'claude-3',
enabled: false,
created_at: '2025-01-02T00:00:00Z',
},
];
describe('models API', () => {
const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('listModels', () => {
it('returns array of Model objects with camelCase keys', async () => {
server.use(
http.get('/api/models', () => {
return HttpResponse.json(mockModels);
}),
);
const result = await listModels();
expect(result).toEqual([
{
id: 'gpt-4',
providerId: 'prov-1',
modelName: 'gpt-4',
enabled: true,
createdAt: '2025-01-01T00:00:00Z',
},
{
id: 'claude-3',
providerId: 'prov-2',
modelName: 'claude-3',
enabled: false,
createdAt: '2025-01-02T00:00:00Z',
},
]);
});
it('appends provider_id query parameter when providerId is given', async () => {
let receivedUrl: string | null = null;
server.use(
http.get('/api/models', ({ request }) => {
receivedUrl = request.url;
return HttpResponse.json([mockModels[0]]);
}),
);
const result = await listModels('prov-1');
expect(receivedUrl).toContain('provider_id=prov-1');
expect(result).toHaveLength(1);
expect(result[0].providerId).toBe('prov-1');
});
});
describe('createModel', () => {
it('sends POST with correct body and returns model', async () => {
let receivedMethod: string | null = null;
let receivedBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/models', async ({ request }) => {
receivedMethod = request.method;
receivedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json(mockModels[0]);
}),
);
const input = {
id: 'gpt-4',
providerId: 'prov-1',
modelName: 'gpt-4',
enabled: true,
};
const result = await createModel(input);
expect(receivedMethod).toBe('POST');
expect(receivedBody).toEqual({
id: 'gpt-4',
provider_id: 'prov-1',
model_name: 'gpt-4',
enabled: true,
});
expect(result.id).toBe('gpt-4');
expect(result.providerId).toBe('prov-1');
expect(result.modelName).toBe('gpt-4');
});
});
describe('updateModel', () => {
it('sends PUT with correct body and returns model', async () => {
let receivedMethod: string | null = null;
let receivedUrl: string | null = null;
let receivedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/models/:id', async ({ request }) => {
receivedMethod = request.method;
receivedUrl = new URL(request.url).pathname;
receivedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({
...mockModels[0],
model_name: 'gpt-4-turbo',
enabled: false,
});
}),
);
const result = await updateModel('gpt-4', {
modelName: 'gpt-4-turbo',
enabled: false,
});
expect(receivedMethod).toBe('PUT');
expect(receivedUrl).toBe('/api/models/gpt-4');
expect(receivedBody).toEqual({
model_name: 'gpt-4-turbo',
enabled: false,
});
expect(result.modelName).toBe('gpt-4-turbo');
expect(result.enabled).toBe(false);
});
});
describe('deleteModel', () => {
it('sends DELETE and returns void', async () => {
let receivedMethod: string | null = null;
let receivedUrl: string | null = null;
server.use(
http.delete('/api/models/:id', ({ request }) => {
receivedMethod = request.method;
receivedUrl = new URL(request.url).pathname;
return new HttpResponse(null, { status: 204 });
}),
);
const result = await deleteModel('gpt-4');
expect(receivedMethod).toBe('DELETE');
expect(receivedUrl).toBe('/api/models/gpt-4');
expect(result).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,165 @@
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { listProviders, createProvider, updateProvider, deleteProvider } from '@/api/providers';
const mockProviders = [
{
id: 'prov-1',
name: 'OpenAI',
api_key: 'sk-xxx',
base_url: 'https://api.openai.com',
enabled: true,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 'prov-2',
name: 'Anthropic',
api_key: 'sk-yyy',
base_url: 'https://api.anthropic.com',
enabled: false,
created_at: '2025-01-02T00:00:00Z',
updated_at: '2025-01-02T00:00:00Z',
},
];
describe('providers API', () => {
const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('listProviders', () => {
it('returns array of Provider objects with camelCase keys', async () => {
server.use(
http.get('/api/providers', () => {
return HttpResponse.json(mockProviders);
}),
);
const result = await listProviders();
expect(result).toEqual([
{
id: 'prov-1',
name: 'OpenAI',
apiKey: 'sk-xxx',
baseUrl: 'https://api.openai.com',
enabled: true,
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
},
{
id: 'prov-2',
name: 'Anthropic',
apiKey: 'sk-yyy',
baseUrl: 'https://api.anthropic.com',
enabled: false,
createdAt: '2025-01-02T00:00:00Z',
updatedAt: '2025-01-02T00:00:00Z',
},
]);
});
});
describe('createProvider', () => {
it('sends POST with correct body and returns provider', async () => {
let receivedMethod: string | null = null;
let receivedBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/providers', async ({ request }) => {
receivedMethod = request.method;
receivedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json(mockProviders[0]);
}),
);
const input = {
id: 'prov-1',
name: 'OpenAI',
apiKey: 'sk-xxx',
baseUrl: 'https://api.openai.com',
enabled: true,
};
const result = await createProvider(input);
expect(receivedMethod).toBe('POST');
expect(receivedBody).toEqual({
id: 'prov-1',
name: 'OpenAI',
api_key: 'sk-xxx',
base_url: 'https://api.openai.com',
enabled: true,
});
expect(result).toEqual({
id: 'prov-1',
name: 'OpenAI',
apiKey: 'sk-xxx',
baseUrl: 'https://api.openai.com',
enabled: true,
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
});
});
});
describe('updateProvider', () => {
it('sends PUT with correct body and returns provider', async () => {
let receivedMethod: string | null = null;
let receivedUrl: string | null = null;
let receivedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/providers/:id', async ({ request, params }) => {
receivedMethod = request.method;
receivedUrl = new URL(request.url).pathname;
receivedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({
...mockProviders[0],
name: 'Updated',
api_key: 'sk-updated',
});
}),
);
const result = await updateProvider('prov-1', {
name: 'Updated',
apiKey: 'sk-updated',
});
expect(receivedMethod).toBe('PUT');
expect(receivedUrl).toBe('/api/providers/prov-1');
expect(receivedBody).toEqual({
name: 'Updated',
api_key: 'sk-updated',
});
expect(result.name).toBe('Updated');
expect(result.apiKey).toBe('sk-updated');
});
});
describe('deleteProvider', () => {
it('sends DELETE and returns void', async () => {
let receivedMethod: string | null = null;
let receivedUrl: string | null = null;
server.use(
http.delete('/api/providers/:id', ({ request, params }) => {
receivedMethod = request.method;
receivedUrl = new URL(request.url).pathname;
return new HttpResponse(null, { status: 204 });
}),
);
const result = await deleteProvider('prov-1');
expect(receivedMethod).toBe('DELETE');
expect(receivedUrl).toBe('/api/providers/prov-1');
expect(result).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,131 @@
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { getStats } from '@/api/stats';
const mockStats = [
{
id: 1,
provider_id: 'prov-1',
model_name: 'gpt-4',
request_count: 100,
date: '2025-01-15',
},
{
id: 2,
provider_id: 'prov-2',
model_name: 'claude-3',
request_count: 50,
date: '2025-01-16',
},
];
describe('stats API', () => {
const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('getStats', () => {
it('calls /api/stats without params', async () => {
let receivedUrl: string | null = null;
server.use(
http.get('/api/stats', ({ request }) => {
receivedUrl = request.url;
return HttpResponse.json(mockStats);
}),
);
const result = await getStats();
expect(receivedUrl).toMatch(/\/api\/stats$/);
expect(result).toEqual([
{
id: 1,
providerId: 'prov-1',
modelName: 'gpt-4',
requestCount: 100,
date: '2025-01-15',
},
{
id: 2,
providerId: 'prov-2',
modelName: 'claude-3',
requestCount: 50,
date: '2025-01-16',
},
]);
});
it('builds correct query string with snake_case keys when params are provided', async () => {
let receivedUrl: string | null = null;
server.use(
http.get('/api/stats', ({ request }) => {
receivedUrl = request.url;
return HttpResponse.json([]);
}),
);
await getStats({
providerId: 'prov-1',
modelName: 'gpt-4',
startDate: '2025-01-01',
endDate: '2025-01-31',
});
expect(receivedUrl).toContain('provider_id=prov-1');
expect(receivedUrl).toContain('model_name=gpt-4');
expect(receivedUrl).toContain('start_date=2025-01-01');
expect(receivedUrl).toContain('end_date=2025-01-31');
});
it('omits undefined params from query string', async () => {
let receivedUrl: string | null = null;
server.use(
http.get('/api/stats', ({ request }) => {
receivedUrl = request.url;
return HttpResponse.json([]);
}),
);
await getStats({
providerId: 'prov-1',
});
expect(receivedUrl).toContain('provider_id=prov-1');
expect(receivedUrl).not.toContain('model_name');
expect(receivedUrl).not.toContain('start_date');
expect(receivedUrl).not.toContain('end_date');
});
it('returns UsageStats array with camelCase keys', async () => {
server.use(
http.get('/api/stats', () => {
return HttpResponse.json(mockStats);
}),
);
const result = await getStats();
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
id: 1,
providerId: 'prov-1',
modelName: 'gpt-4',
requestCount: 100,
date: '2025-01-15',
});
expect(result[1]).toEqual({
id: 2,
providerId: 'prov-2',
modelName: 'claude-3',
requestCount: 50,
date: '2025-01-16',
});
});
});
});

View File

@@ -0,0 +1,144 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { ModelForm } from '@/pages/Providers/ModelForm';
import type { Provider, Model } from '@/types';
const mockProviders: Provider[] = [
{
id: 'openai',
name: 'OpenAI',
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
enabled: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
{
id: 'anthropic',
name: 'Anthropic',
apiKey: 'sk-ant-test',
baseUrl: 'https://api.anthropic.com',
enabled: true,
createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
},
];
const mockModel: Model = {
id: 'gpt-4o',
providerId: 'openai',
modelName: 'gpt-4o',
enabled: true,
createdAt: '2024-01-01T00:00:00Z',
};
const defaultProps = {
open: true,
providerId: 'openai',
providers: mockProviders,
onSave: vi.fn(),
onCancel: vi.fn(),
loading: false,
};
function getDialog() {
return screen.getByRole('dialog');
}
describe('ModelForm', () => {
it('renders form with provider select', () => {
render(<ModelForm {...defaultProps} />);
const dialog = getDialog();
expect(within(dialog).getByText('添加模型')).toBeInTheDocument();
expect(within(dialog).getByText('ID')).toBeInTheDocument();
expect(within(dialog).getByText('供应商')).toBeInTheDocument();
expect(within(dialog).getByText('模型名称')).toBeInTheDocument();
expect(within(dialog).getByText('启用')).toBeInTheDocument();
// The selected provider (OpenAI) is shown; Anthropic is not rendered until dropdown opens
expect(within(dialog).getByText('OpenAI')).toBeInTheDocument();
});
it('defaults providerId to the passed providerId in create mode', () => {
render(<ModelForm {...defaultProps} />);
const dialog = getDialog();
const selectionItem = dialog.querySelector('.ant-select-selection-item');
expect(selectionItem).toBeInTheDocument();
expect(selectionItem?.textContent).toBe('OpenAI');
});
it('shows validation error messages for required fields', async () => {
const user = userEvent.setup();
render(
<ModelForm
{...defaultProps}
providerId={undefined as unknown as string}
providers={[]}
/>,
);
const dialog = getDialog();
const okButton = within(dialog).getByRole('button', { name: /保/ });
await user.click(okButton);
expect(await screen.findByText('请输入模型 ID')).toBeInTheDocument();
expect(screen.getByText('请选择供应商')).toBeInTheDocument();
expect(screen.getByText('请输入模型名称')).toBeInTheDocument();
});
it('calls onSave with form values on successful submission', async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<ModelForm {...defaultProps} onSave={onSave} />);
const dialog = getDialog();
// There are two inputs with placeholder "例如: gpt-4o": ID field (index 0) and model name (index 1)
const inputs = within(dialog).getAllByPlaceholderText('例如: gpt-4o');
// Type into the ID field
await user.type(inputs[0], 'gpt-4o-mini');
// Type into the model name field
await user.type(inputs[1], 'gpt-4o-mini');
const okButton = within(dialog).getByRole('button', { name: /保/ });
await user.click(okButton);
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
id: 'gpt-4o-mini',
providerId: 'openai',
modelName: 'gpt-4o-mini',
enabled: true,
}),
);
});
it('renders pre-filled fields in edit mode', () => {
render(<ModelForm {...defaultProps} model={mockModel} />);
const dialog = getDialog();
expect(within(dialog).getByText('编辑模型')).toBeInTheDocument();
const inputs = within(dialog).getAllByPlaceholderText('例如: gpt-4o');
const idInput = inputs[0] as HTMLInputElement;
expect(idInput.value).toBe('gpt-4o');
expect(idInput).toBeDisabled();
const modelNameInput = inputs[1] as HTMLInputElement;
expect(modelNameInput.value).toBe('gpt-4o');
});
it('calls onCancel when clicking cancel button', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(<ModelForm {...defaultProps} onCancel={onCancel} />);
const dialog = getDialog();
const cancelButton = within(dialog).getByRole('button', { name: /取/ });
await user.click(cancelButton);
expect(onCancel).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,147 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { ProviderForm } from '@/pages/Providers/ProviderForm';
import type { Provider } from '@/types';
const mockProvider: Provider = {
id: 'openai',
name: 'OpenAI',
apiKey: 'sk-old-key',
baseUrl: 'https://api.openai.com/v1',
enabled: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
const defaultProps = {
open: true,
onSave: vi.fn(),
onCancel: vi.fn(),
loading: false,
};
function getDialog() {
return screen.getByRole('dialog');
}
describe('ProviderForm', () => {
it('renders form fields in create mode', () => {
render(<ProviderForm {...defaultProps} />);
const dialog = getDialog();
expect(within(dialog).getByText('添加供应商')).toBeInTheDocument();
expect(within(dialog).getByText('ID')).toBeInTheDocument();
expect(within(dialog).getByText('名称')).toBeInTheDocument();
expect(within(dialog).getByText('API Key')).toBeInTheDocument();
expect(within(dialog).getByText('Base URL')).toBeInTheDocument();
expect(within(dialog).getByText('启用')).toBeInTheDocument();
expect(within(dialog).getByPlaceholderText('例如: openai')).toBeInTheDocument();
expect(within(dialog).getByPlaceholderText('例如: OpenAI')).toBeInTheDocument();
expect(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1')).toBeInTheDocument();
});
it('renders pre-filled fields in edit mode', () => {
render(<ProviderForm {...defaultProps} provider={mockProvider} />);
const dialog = getDialog();
expect(within(dialog).getByText('编辑供应商')).toBeInTheDocument();
const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement;
expect(idInput.value).toBe('openai');
expect(idInput).toBeDisabled();
const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement;
expect(nameInput.value).toBe('OpenAI');
const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement;
expect(baseUrlInput.value).toBe('https://api.openai.com/v1');
});
it('shows API Key label variant in edit mode', () => {
render(<ProviderForm {...defaultProps} provider={mockProvider} />);
const dialog = getDialog();
expect(within(dialog).getByText('API Key留空则不修改')).toBeInTheDocument();
});
it('shows validation error messages for required fields', async () => {
const user = userEvent.setup();
render(<ProviderForm {...defaultProps} />);
const dialog = getDialog();
const okButton = within(dialog).getByRole('button', { name: /保/ });
await user.click(okButton);
// Wait for validation messages to appear
expect(await screen.findByText('请输入供应商 ID')).toBeInTheDocument();
expect(screen.getByText('请输入名称')).toBeInTheDocument();
expect(screen.getByText('请输入 API Key')).toBeInTheDocument();
expect(screen.getByText('请输入 Base URL')).toBeInTheDocument();
});
it('calls onSave with form values on successful submission', async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<ProviderForm {...defaultProps} onSave={onSave} />);
const dialog = getDialog();
await user.type(within(dialog).getByPlaceholderText('例如: openai'), 'test-provider');
await user.type(within(dialog).getByPlaceholderText('例如: OpenAI'), 'Test Provider');
await user.type(within(dialog).getByPlaceholderText('sk-...'), 'sk-test-key');
await user.type(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1'), 'https://api.test.com/v1');
const okButton = within(dialog).getByRole('button', { name: /保/ });
await user.click(okButton);
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
id: 'test-provider',
name: 'Test Provider',
apiKey: 'sk-test-key',
baseUrl: 'https://api.test.com/v1',
enabled: true,
}),
);
});
it('calls onCancel when clicking cancel button', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(<ProviderForm {...defaultProps} onCancel={onCancel} />);
const dialog = getDialog();
const cancelButton = within(dialog).getByRole('button', { name: /取/ });
await user.click(cancelButton);
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('shows confirm loading state', () => {
render(<ProviderForm {...defaultProps} loading={true} />);
const dialog = getDialog();
const okButton = within(dialog).getByRole('button', { name: /保/ });
expect(okButton).toHaveClass('ant-btn-loading');
});
it('shows validation error for invalid URL format', async () => {
const user = userEvent.setup();
render(<ProviderForm {...defaultProps} />);
const dialog = getDialog();
// Fill in required fields
await user.type(within(dialog).getByPlaceholderText('例如: openai'), 'test-provider');
await user.type(within(dialog).getByPlaceholderText('例如: OpenAI'), 'Test Provider');
await user.type(within(dialog).getByPlaceholderText('sk-...'), 'sk-test-key');
// Enter an invalid URL in the Base URL field
await user.type(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1'), 'not-a-url');
// Submit the form
const okButton = within(dialog).getByRole('button', { name: /保/ });
await user.click(okButton);
// Verify that a URL validation error message appears
expect(await screen.findByText('请输入有效的 URL')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,140 @@
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { ProviderTable } from '@/pages/Providers/ProviderTable';
import type { Provider } from '@/types';
const mockModelsData = [
{ id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true },
{ id: 'model-2', providerId: 'openai', modelName: 'gpt-3.5-turbo', enabled: false },
];
vi.mock('@/hooks/useModels', () => ({
useModels: vi.fn(() => ({ data: mockModelsData, isLoading: false })),
useDeleteModel: vi.fn(() => ({ mutate: vi.fn() })),
}));
const mockProviders: Provider[] = [
{
id: 'openai',
name: 'OpenAI',
apiKey: 'sk-abcdefgh12345678',
baseUrl: 'https://api.openai.com/v1',
enabled: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
{
id: 'anthropic',
name: 'Anthropic',
apiKey: 'sk-ant-test',
baseUrl: 'https://api.anthropic.com',
enabled: false,
createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
},
];
const defaultProps = {
providers: mockProviders,
loading: false,
onAdd: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
onAddModel: vi.fn(),
onEditModel: vi.fn(),
};
describe('ProviderTable', () => {
it('renders provider list with name, baseUrl, masked apiKey, and status tags', () => {
render(<ProviderTable {...defaultProps} />);
expect(screen.getByText('供应商列表')).toBeInTheDocument();
expect(screen.getByText('OpenAI')).toBeInTheDocument();
expect(screen.getByText('https://api.openai.com/v1')).toBeInTheDocument();
expect(screen.getByText('****5678')).toBeInTheDocument();
expect(screen.getByText('Anthropic')).toBeInTheDocument();
expect(screen.getByText('https://api.anthropic.com')).toBeInTheDocument();
expect(screen.getByText('****test')).toBeInTheDocument();
const enabledTags = screen.getAllByText('启用');
const disabledTags = screen.getAllByText('禁用');
expect(enabledTags.length).toBeGreaterThanOrEqual(1);
expect(disabledTags.length).toBeGreaterThanOrEqual(1);
});
it('renders short api keys fully masked', () => {
const shortKeyProvider: Provider[] = [
{
...mockProviders[0],
id: 'short',
name: 'ShortKey',
apiKey: 'ab',
},
];
render(<ProviderTable {...defaultProps} providers={shortKeyProvider} />);
expect(screen.getByText('****')).toBeInTheDocument();
});
it('calls onAdd when clicking "添加供应商" button', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
render(<ProviderTable {...defaultProps} onAdd={onAdd} />);
await user.click(screen.getByRole('button', { name: '添加供应商' }));
expect(onAdd).toHaveBeenCalledTimes(1);
});
it('calls onEdit with correct provider when clicking "编辑"', async () => {
const user = userEvent.setup();
const onEdit = vi.fn();
render(<ProviderTable {...defaultProps} onEdit={onEdit} />);
const editButtons = screen.getAllByRole('button', { name: '编辑' });
await user.click(editButtons[0]);
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onEdit).toHaveBeenCalledWith(mockProviders[0]);
});
it('calls onDelete with correct provider ID when delete is confirmed', async () => {
const user = userEvent.setup();
const onDelete = vi.fn();
render(<ProviderTable {...defaultProps} onDelete={onDelete} />);
// Find and click the delete button for the first row
const deleteButtons = screen.getAllByRole('button', { name: '删除' });
await user.click(deleteButtons[0]);
// Find and click the "确 定" confirm button in the Popconfirm popup
// antd renders the text with spaces between Chinese characters
const confirmButtons = await screen.findAllByText('确 定');
await user.click(confirmButtons[0]);
// Assert that onDelete was called with the correct provider ID
expect(onDelete).toHaveBeenCalledTimes(1);
expect(onDelete).toHaveBeenCalledWith('openai');
});
it('shows loading state', () => {
render(<ProviderTable {...defaultProps} loading={true} />);
expect(document.querySelector('.ant-spin')).toBeInTheDocument();
});
it('renders expandable ModelTable when row is expanded', async () => {
const user = userEvent.setup();
render(<ProviderTable {...defaultProps} />);
// Find and click the expand button for the first row
const expandButtons = screen.getAllByRole('button', { name: /expand/i });
expect(expandButtons.length).toBeGreaterThanOrEqual(1);
await user.click(expandButtons[0]);
// Verify that ModelTable content is rendered with data from mocked useModels
expect(await screen.findByText('gpt-4o')).toBeInTheDocument();
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,159 @@
import { render, screen, within, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { StatsTable } from '@/pages/Stats/StatsTable';
import type { Provider, UsageStats } from '@/types';
const mockProviders: Provider[] = [
{
id: 'openai',
name: 'OpenAI',
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
enabled: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
{
id: 'anthropic',
name: 'Anthropic',
apiKey: 'sk-ant-test',
baseUrl: 'https://api.anthropic.com',
enabled: true,
createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
},
];
const mockStats: UsageStats[] = [
{
id: 1,
providerId: 'openai',
modelName: 'gpt-4o',
requestCount: 100,
date: '2024-01-15',
},
{
id: 2,
providerId: 'anthropic',
modelName: 'claude-3-opus',
requestCount: 50,
date: '2024-01-15',
},
];
const mockUseStats = vi.fn(() => ({
data: mockStats,
isLoading: false,
}));
vi.mock('@/hooks/useStats', () => ({
useStats: (...args: unknown[]) => mockUseStats(...args),
}));
describe('StatsTable', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('renders stats table with data', () => {
render(<StatsTable providers={mockProviders} />);
expect(screen.getByText('gpt-4o')).toBeInTheDocument();
expect(screen.getByText('claude-3-opus')).toBeInTheDocument();
// Both rows share the same date
const dateCells = screen.getAllByText('2024-01-15');
expect(dateCells.length).toBe(2);
expect(screen.getByText('100')).toBeInTheDocument();
expect(screen.getByText('50')).toBeInTheDocument();
});
it('shows provider name from providers prop instead of providerId', () => {
render(<StatsTable providers={mockProviders} />);
expect(screen.getByText('OpenAI')).toBeInTheDocument();
// "Anthropic" appears in both the provider column and the filter select options
const allAnthropic = screen.getAllByText('Anthropic');
expect(allAnthropic.length).toBeGreaterThanOrEqual(1);
});
it('renders filter controls with Select, Input, and DatePicker', () => {
render(<StatsTable providers={mockProviders} />);
// Check that the select element exists for provider filter
const selects = document.querySelectorAll('.ant-select');
expect(selects.length).toBeGreaterThanOrEqual(1);
// Check that the Input element exists for model name filter
const modelInput = screen.getByPlaceholderText('模型名称');
expect(modelInput).toBeInTheDocument();
// Verify placeholder text is rendered
expect(screen.getByText('所有供应商')).toBeInTheDocument();
const rangePicker = document.querySelector('.ant-picker-range');
expect(rangePicker).toBeInTheDocument();
});
it('renders table headers correctly', () => {
render(<StatsTable providers={mockProviders} />);
expect(screen.getByText('供应商')).toBeInTheDocument();
expect(screen.getByText('模型')).toBeInTheDocument();
expect(screen.getByText('日期')).toBeInTheDocument();
expect(screen.getByText('请求数')).toBeInTheDocument();
});
it('falls back to providerId when provider not found in providers prop', () => {
const limitedProviders = [mockProviders[0]]; // only OpenAI
render(<StatsTable providers={limitedProviders} />);
// OpenAI should show name
expect(screen.getByText('OpenAI')).toBeInTheDocument();
// Anthropic is not in providers list, so providerId "anthropic" should show
expect(screen.getByText('anthropic')).toBeInTheDocument();
});
it('renders with empty stats data', () => {
mockUseStats.mockReturnValueOnce({
data: [],
isLoading: false,
});
render(<StatsTable providers={mockProviders} />);
// Table should still be rendered, just empty
expect(screen.getByText('供应商')).toBeInTheDocument();
expect(screen.getByText('模型')).toBeInTheDocument();
});
it('updates provider filter when selecting a provider', () => {
render(<StatsTable providers={mockProviders} />);
// Initially useStats should be called with no providerId filter
expect(mockUseStats).toHaveBeenLastCalledWith(
expect.objectContaining({
providerId: undefined,
}),
);
// Find the provider Select and change its value
const selectElement = document.querySelector('.ant-select');
expect(selectElement).toBeInTheDocument();
// Open the select dropdown
fireEvent.mouseDown(selectElement!.querySelector('.ant-select-selector')!);
// Click on the "OpenAI" option from the dropdown
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toBeInTheDocument();
const openaiOption = within(dropdown as HTMLElement).getByText('OpenAI');
fireEvent.click(openaiOption);
// After selecting, useStats should be called with providerId set to 'openai'
expect(mockUseStats).toHaveBeenLastCalledWith(
expect.objectContaining({
providerId: 'openai',
}),
);
});
});

View File

@@ -0,0 +1,287 @@
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { useModels, useCreateModel, useUpdateModel, useDeleteModel } from '@/hooks/useModels';
import type { Model, CreateModelInput, UpdateModelInput } from '@/types';
// Mock antd message since it uses DOM APIs not available in jsdom
vi.mock('antd', () => ({
message: {
success: vi.fn(),
error: vi.fn(),
},
}));
import { message } from 'antd';
// Test data
const mockModels: Model[] = [
{
id: 'model-1',
providerId: 'provider-1',
modelName: 'gpt-4o',
enabled: true,
createdAt: '2026-01-01T00:00:00Z',
},
{
id: 'model-2',
providerId: 'provider-1',
modelName: 'gpt-4o-mini',
enabled: true,
createdAt: '2026-01-02T00:00:00Z',
},
];
const mockFilteredModels: Model[] = [
{
id: 'model-3',
providerId: 'provider-2',
modelName: 'claude-sonnet-4-5',
enabled: true,
createdAt: '2026-02-01T00:00:00Z',
},
];
const mockCreatedModel: Model = {
id: 'model-4',
providerId: 'provider-1',
modelName: 'gpt-4.1',
enabled: true,
createdAt: '2026-03-01T00:00:00Z',
};
// MSW handlers
const handlers = [
http.get('/api/models', ({ request }) => {
const url = new URL(request.url);
const providerId = url.searchParams.get('provider_id');
if (providerId === 'provider-2') {
return HttpResponse.json(mockFilteredModels);
}
return HttpResponse.json(mockModels);
}),
http.post('/api/models', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({
...mockCreatedModel,
...body,
});
}),
http.put('/api/models/:id', async ({ request, params }) => {
const body = await request.json() as Record<string, unknown>;
const existing = mockModels.find((m) => m.id === params['id']);
return HttpResponse.json({ ...existing, ...body });
}),
http.delete('/api/models/:id', () => {
return new HttpResponse(null, { status: 204 });
}),
];
const server = setupServer(...handlers);
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function createWrapper() {
const testQueryClient = createTestQueryClient();
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
);
};
}
beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
vi.clearAllMocks();
});
afterAll(() => server.close());
describe('useModels', () => {
it('fetches model list', async () => {
const { result } = renderHook(() => useModels(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockModels);
expect(result.current.data).toHaveLength(2);
expect(result.current.data![0]!.modelName).toBe('gpt-4o');
});
it('with providerId passes it to API and returns filtered models', async () => {
const { result } = renderHook(() => useModels('provider-2'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockFilteredModels);
expect(result.current.data).toHaveLength(1);
expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5');
});
});
describe('useCreateModel', () => {
it('calls API and invalidates model queries', async () => {
const queryClient = createTestQueryClient();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
const { result } = renderHook(() => useCreateModel(), {
wrapper: Wrapper,
});
const input: CreateModelInput = {
id: 'model-4',
providerId: 'provider-1',
modelName: 'gpt-4.1',
enabled: true,
};
result.current.mutate(input);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toMatchObject({
id: 'model-4',
modelName: 'gpt-4.1',
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] });
expect(message.success).toHaveBeenCalledWith('模型创建成功');
});
it('calls message.error on failure', async () => {
server.use(
http.post('/api/models', () => {
return HttpResponse.json({ message: '创建失败' }, { status: 500 });
}),
);
const { result } = renderHook(() => useCreateModel(), {
wrapper: createWrapper(),
});
const input: CreateModelInput = {
id: 'model-4',
providerId: 'provider-1',
modelName: 'gpt-4.1',
enabled: true,
};
result.current.mutate(input);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(message.error).toHaveBeenCalled();
});
});
describe('useUpdateModel', () => {
it('calls API and invalidates model queries', async () => {
const queryClient = createTestQueryClient();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
const { result } = renderHook(() => useUpdateModel(), {
wrapper: Wrapper,
});
const input: UpdateModelInput = { modelName: 'gpt-4o-updated' };
result.current.mutate({ id: 'model-1', input });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toMatchObject({
modelName: 'gpt-4o-updated',
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] });
expect(message.success).toHaveBeenCalledWith('模型更新成功');
});
it('calls message.error on failure', async () => {
server.use(
http.put('/api/models/:id', () => {
return HttpResponse.json({ message: '更新失败' }, { status: 500 });
}),
);
const { result } = renderHook(() => useUpdateModel(), {
wrapper: createWrapper(),
});
result.current.mutate({ id: 'model-1', input: { modelName: 'Updated' } });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(message.error).toHaveBeenCalled();
});
});
describe('useDeleteModel', () => {
it('calls API and invalidates model queries', async () => {
const queryClient = createTestQueryClient();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
const { result } = renderHook(() => useDeleteModel(), {
wrapper: Wrapper,
});
result.current.mutate('model-1');
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] });
expect(message.success).toHaveBeenCalledWith('模型删除成功');
});
it('calls message.error on failure', async () => {
server.use(
http.delete('/api/models/:id', () => {
return HttpResponse.json({ message: '删除失败' }, { status: 500 });
}),
);
const { result } = renderHook(() => useDeleteModel(), {
wrapper: createWrapper(),
});
result.current.mutate('model-1');
await waitFor(() => expect(result.current.isError).toBe(true));
expect(message.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,270 @@
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders';
import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types';
// Mock antd message since it uses DOM APIs not available in jsdom
vi.mock('antd', () => ({
message: {
success: vi.fn(),
error: vi.fn(),
},
}));
// Import the mocked message for assertions
import { message } from 'antd';
// Test data
const mockProviders: Provider[] = [
{
id: 'provider-1',
name: 'OpenAI',
apiKey: 'sk-xxx',
baseUrl: 'https://api.openai.com',
enabled: true,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
},
{
id: 'provider-2',
name: 'Anthropic',
apiKey: 'sk-yyy',
baseUrl: 'https://api.anthropic.com',
enabled: false,
createdAt: '2026-02-01T00:00:00Z',
updatedAt: '2026-02-01T00:00:00Z',
},
];
const mockCreatedProvider: Provider = {
id: 'provider-3',
name: 'NewProvider',
apiKey: 'sk-zzz',
baseUrl: 'https://api.newprovider.com',
enabled: true,
createdAt: '2026-03-01T00:00:00Z',
updatedAt: '2026-03-01T00:00:00Z',
};
// MSW handlers
const handlers = [
http.get('/api/providers', () => {
return HttpResponse.json(mockProviders);
}),
http.post('/api/providers', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({
...mockCreatedProvider,
...body,
});
}),
http.put('/api/providers/:id', async ({ request, params }) => {
const body = await request.json() as Record<string, unknown>;
const existing = mockProviders.find((p) => p.id === params['id']);
return HttpResponse.json({ ...existing, ...body });
}),
http.delete('/api/providers/:id', () => {
return new HttpResponse(null, { status: 204 });
}),
];
const server = setupServer(...handlers);
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function createWrapper() {
const testQueryClient = createTestQueryClient();
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
);
};
}
beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
vi.clearAllMocks();
});
afterAll(() => server.close());
describe('useProviders', () => {
it('fetches and returns provider list', async () => {
const { result } = renderHook(() => useProviders(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockProviders);
expect(result.current.data).toHaveLength(2);
expect(result.current.data![0]!.name).toBe('OpenAI');
expect(result.current.data![1]!.name).toBe('Anthropic');
});
});
describe('useCreateProvider', () => {
it('calls API and invalidates provider queries', async () => {
const queryClient = createTestQueryClient();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
const { result } = renderHook(() => useCreateProvider(), {
wrapper: Wrapper,
});
const input: CreateProviderInput = {
id: 'provider-3',
name: 'NewProvider',
apiKey: 'sk-zzz',
baseUrl: 'https://api.newprovider.com',
enabled: true,
};
result.current.mutate(input);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toMatchObject({
id: 'provider-3',
name: 'NewProvider',
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] });
expect(message.success).toHaveBeenCalledWith('供应商创建成功');
});
it('calls message.error on failure', async () => {
server.use(
http.post('/api/providers', () => {
return HttpResponse.json({ message: '创建失败' }, { status: 500 });
}),
);
const { result } = renderHook(() => useCreateProvider(), {
wrapper: createWrapper(),
});
const input: CreateProviderInput = {
id: 'provider-3',
name: 'NewProvider',
apiKey: 'sk-zzz',
baseUrl: 'https://api.newprovider.com',
enabled: true,
};
result.current.mutate(input);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(message.error).toHaveBeenCalled();
});
});
describe('useUpdateProvider', () => {
it('calls API and invalidates provider queries', async () => {
const queryClient = createTestQueryClient();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
const { result } = renderHook(() => useUpdateProvider(), {
wrapper: Wrapper,
});
const input: UpdateProviderInput = { name: 'UpdatedProvider' };
result.current.mutate({ id: 'provider-1', input });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toMatchObject({
name: 'UpdatedProvider',
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] });
expect(message.success).toHaveBeenCalledWith('供应商更新成功');
});
it('calls message.error on failure', async () => {
server.use(
http.put('/api/providers/:id', () => {
return HttpResponse.json({ message: '更新失败' }, { status: 500 });
}),
);
const { result } = renderHook(() => useUpdateProvider(), {
wrapper: createWrapper(),
});
result.current.mutate({ id: 'provider-1', input: { name: 'Updated' } });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(message.error).toHaveBeenCalled();
});
});
describe('useDeleteProvider', () => {
it('calls API and invalidates provider queries', async () => {
const queryClient = createTestQueryClient();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
const { result } = renderHook(() => useDeleteProvider(), {
wrapper: Wrapper,
});
result.current.mutate('provider-1');
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] });
expect(message.success).toHaveBeenCalledWith('供应商删除成功');
});
it('calls message.error on failure', async () => {
server.use(
http.delete('/api/providers/:id', () => {
return HttpResponse.json({ message: '删除失败' }, { status: 500 });
}),
);
const { result } = renderHook(() => useDeleteProvider(), {
wrapper: createWrapper(),
});
result.current.mutate('provider-1');
await waitFor(() => expect(result.current.isError).toBe(true));
expect(message.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,140 @@
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { useStats } from '@/hooks/useStats';
import type { UsageStats, StatsQueryParams } from '@/types';
// Test data
const mockStats: UsageStats[] = [
{
id: 1,
providerId: 'provider-1',
modelName: 'gpt-4o',
requestCount: 100,
date: '2026-04-01',
},
{
id: 2,
providerId: 'provider-1',
modelName: 'gpt-4o-mini',
requestCount: 50,
date: '2026-04-01',
},
];
const mockFilteredStats: UsageStats[] = [
{
id: 3,
providerId: 'provider-2',
modelName: 'claude-sonnet-4-5',
requestCount: 200,
date: '2026-04-01',
},
];
// Track the request URL for assertions
let capturedUrl: URL | null = null;
// MSW handlers
const handlers = [
http.get('/api/stats', ({ request }) => {
capturedUrl = new URL(request.url);
const providerId = capturedUrl.searchParams.get('provider_id');
if (providerId === 'provider-2') {
return HttpResponse.json(mockFilteredStats);
}
return HttpResponse.json(mockStats);
}),
];
const server = setupServer(...handlers);
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function createWrapper() {
const testQueryClient = createTestQueryClient();
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
);
};
}
beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
capturedUrl = null;
});
afterAll(() => server.close());
describe('useStats', () => {
it('fetches stats without params', async () => {
const { result } = renderHook(() => useStats(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockStats);
expect(result.current.data).toHaveLength(2);
expect(result.current.data![0]!.modelName).toBe('gpt-4o');
expect(result.current.data![1]!.requestCount).toBe(50);
// Verify no query params were sent
expect(capturedUrl!.search).toBe('');
});
it('with filter params passes them correctly', async () => {
const params: StatsQueryParams = {
providerId: 'provider-2',
modelName: 'claude-sonnet-4-5',
startDate: '2026-04-01',
endDate: '2026-04-15',
};
const { result } = renderHook(() => useStats(params), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockFilteredStats);
expect(result.current.data).toHaveLength(1);
expect(result.current.data![0]!.modelName).toBe('claude-sonnet-4-5');
// Verify query params were passed correctly (snake_case)
expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-2');
expect(capturedUrl!.searchParams.get('model_name')).toBe('claude-sonnet-4-5');
expect(capturedUrl!.searchParams.get('start_date')).toBe('2026-04-01');
expect(capturedUrl!.searchParams.get('end_date')).toBe('2026-04-15');
});
it('with partial filter params only sends provided params', async () => {
const params: StatsQueryParams = {
providerId: 'provider-1',
};
const { result } = renderHook(() => useStats(params), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// Verify only provider_id was sent
expect(capturedUrl!.searchParams.get('provider_id')).toBe('provider-1');
expect(capturedUrl!.searchParams.get('model_name')).toBeNull();
expect(capturedUrl!.searchParams.get('start_date')).toBeNull();
expect(capturedUrl!.searchParams.get('end_date')).toBeNull();
});
});

View File

@@ -0,0 +1,26 @@
import '@testing-library/jest-dom/vitest';
// Polyfill window.matchMedia for jsdom (required by antd)
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
// Polyfill window.getComputedStyle to suppress jsdom warnings
const originalGetComputedStyle = window.getComputedStyle;
window.getComputedStyle = (elt: Element, pseudoElt?: string | null) => {
try {
return originalGetComputedStyle(elt, pseudoElt);
} catch {
return {} as CSSStyleDeclaration;
}
};