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:
287
frontend/src/__tests__/hooks/useModels.test.tsx
Normal file
287
frontend/src/__tests__/hooks/useModels.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
270
frontend/src/__tests__/hooks/useProviders.test.tsx
Normal file
270
frontend/src/__tests__/hooks/useProviders.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
140
frontend/src/__tests__/hooks/useStats.test.tsx
Normal file
140
frontend/src/__tests__/hooks/useStats.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user