diff --git a/package-lock.json b/package-lock.json index 9727f417..4a9ecad8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@commitlint/config-conventional": "^21.0.0", "@tailwindcss/postcss": "^4.3.0", "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/d3": "^7.4.3", "@types/express": "^5.0.6", @@ -65,9 +66,16 @@ "vitest": "4.1.8" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@ahooksjs/use-request": { "version": "2.8.15", "resolved": "https://registry.npmjs.org/@ahooksjs/use-request/-/use-request-2.8.15.tgz", @@ -8184,6 +8192,33 @@ "node": ">=18" } }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/react": { "version": "16.3.2", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", @@ -15272,6 +15307,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssdb": { "version": "6.6.3", "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-6.6.3.tgz", diff --git a/package.json b/package.json index 219c54ac..c182fda3 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@commitlint/config-conventional": "^21.0.0", "@tailwindcss/postcss": "^4.3.0", "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/d3": "^7.4.3", "@types/express": "^5.0.6", @@ -73,7 +74,7 @@ "@umijs/max": "^4.6.57", "@umijs/max-plugin-openapi": "^2.0.3", "@umijs/request-record": "^1.1.4", - "@vitest/coverage-v8": "4.1.8", +"@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "cross-env": "^10.1.0", "express": "^5.2.1", @@ -81,7 +82,7 @@ "gh-pages": "^6.3.0", "happy-dom": "^20.0.0", "husky": "^9.1.7", - "jsdom": "^29.1.1", +"jsdom": "^29.1.1", "lint-staged": "^17.0.2", "mockjs": "^1.1.0", "react-doctor": "^0.2.1", diff --git a/src/access.test.ts b/src/access.test.ts new file mode 100644 index 00000000..29fe3978 --- /dev/null +++ b/src/access.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import access from './access'; + +describe('access', () => { + it('should return canAdmin true when user has admin access', () => { + const initialState = { + currentUser: { + userid: '1', + name: 'Admin User', + avatar: 'https://example.com/avatar.png', + access: 'admin', + }, + }; + + const result = access(initialState); + + expect(result.canAdmin).toBe(true); + }); + + it('should return canAdmin false when user has non-admin access', () => { + const initialState = { + currentUser: { + userid: '2', + name: 'Regular User', + avatar: 'https://example.com/avatar.png', + access: 'user', + }, + }; + + const result = access(initialState); + + expect(result.canAdmin).toBe(false); + }); + + it('should return canAdmin false when user access is undefined', () => { + const initialState = { + currentUser: { + userid: '3', + name: 'Guest User', + avatar: 'https://example.com/avatar.png', + }, + }; + + const result = access(initialState); + + expect(result.canAdmin).toBe(false); + }); + + it('should return canAdmin false when currentUser is undefined', () => { + const initialState = { + currentUser: undefined, + }; + + const result = access(initialState); + + expect(result.canAdmin).toBeFalsy(); + }); + + it('should return canAdmin false when initialState is undefined', () => { + const result = access(undefined); + + expect(result.canAdmin).toBeFalsy(); + }); +}); diff --git a/src/app.test.tsx b/src/app.test.tsx new file mode 100644 index 00000000..7acf1f66 --- /dev/null +++ b/src/app.test.tsx @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock all heavy dependencies before importing app +const mockReplace = vi.fn(); +const mockHistory = { + location: { + pathname: '/welcome', + search: '', + hash: '', + }, + replace: mockReplace, +}; + +const mockQueryCurrentUser = vi.fn(); + +vi.mock('@umijs/max', () => ({ + history: mockHistory, + Link: ({ children }: any) => children, +})); + +vi.mock('@/services/ant-design-pro/api', () => ({ + currentUser: mockQueryCurrentUser, +})); + +vi.mock('@/components', () => ({ + AvatarDropdown: () => null, + DocLink: () => null, + ErrorBoundary: ({ children }: any) => children, + Footer: () => null, + LangDropdown: () => null, + OfflineBanner: () => null, + VersionDropdown: () => null, +})); + +vi.mock('@ant-design/pro-components', () => ({ + SettingDrawer: () => null, +})); + +vi.mock('@ant-design/icons', () => ({ + LinkOutlined: () => null, +})); + +vi.mock('./requestErrorConfig', () => ({ + errorConfig: {}, +})); + +vi.mock('../config/defaultSettings', () => ({ + default: { navTheme: 'light' }, +})); + +describe('app getInitialState', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockHistory.location = { + pathname: '/welcome', + search: '', + hash: '', + }; + }); + + it('should fetch currentUser when not on login page', async () => { + const { getInitialState } = await import('./app'); + mockQueryCurrentUser.mockResolvedValue({ + data: { + name: 'Test User', + access: 'admin', + }, + }); + + const state = await getInitialState(); + + expect(mockQueryCurrentUser).toHaveBeenCalled(); + expect(state.currentUser).toEqual({ + name: 'Test User', + access: 'admin', + }); + expect(state.settingDrawerOpen).toBe(false); + expect(state.fetchUserInfo).toBeDefined(); + }); + + it('should redirect to login when currentUser fetch fails (401)', async () => { + const { getInitialState } = await import('./app'); + mockQueryCurrentUser.mockRejectedValue(new Error('401 Unauthorized')); + + const state = await getInitialState(); + + expect(mockReplace).toHaveBeenCalledWith( + expect.stringContaining('/user/login?redirect='), + ); + expect(state.currentUser).toBeUndefined(); + }); + + it('should not fetch currentUser on login page', async () => { + const { getInitialState } = await import('./app'); + mockHistory.location = { + pathname: '/user/login', + search: '', + hash: '', + }; + + const state = await getInitialState(); + + expect(mockQueryCurrentUser).not.toHaveBeenCalled(); + expect(state.currentUser).toBeUndefined(); + expect(state.fetchUserInfo).toBeDefined(); + }); + + it('should encode redirect path correctly on 401', async () => { + const { getInitialState } = await import('./app'); + mockHistory.location = { + pathname: '/admin/users', + search: '?page=2', + hash: '#section', + }; + mockQueryCurrentUser.mockRejectedValue(new Error('401')); + + await getInitialState(); + + expect(mockReplace).toHaveBeenCalledWith( + `/user/login?redirect=${encodeURIComponent('/admin/users?page=2#section')}`, + ); + }); + + it('should include default settings in initial state', async () => { + const { getInitialState } = await import('./app'); + mockQueryCurrentUser.mockResolvedValue({ + data: { name: 'User' }, + }); + + const state = await getInitialState(); + + expect(state.settings).toEqual({ navTheme: 'light' }); + }); + + it('fetchUserInfo should return user data on success', async () => { + const { getInitialState } = await import('./app'); + mockQueryCurrentUser.mockResolvedValue({ + data: { name: 'Fetched User', access: 'user' }, + }); + + const state = await getInitialState(); + + const user = await state.fetchUserInfo?.(); + expect(user).toEqual({ name: 'Fetched User', access: 'user' }); + }); +}); diff --git a/src/pages/dashboard/analysis/index.test.tsx b/src/pages/dashboard/analysis/index.test.tsx new file mode 100644 index 00000000..723680b2 --- /dev/null +++ b/src/pages/dashboard/analysis/index.test.tsx @@ -0,0 +1,240 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AnalysisData } from './data.d'; +import * as service from './service'; + +// Mock ProComponents +vi.mock('@ant-design/pro-components', () => ({ + GridContent: ({ children }: any) => ( +
{children}
+ ), +})); + +// Mock dependencies +vi.mock('@umijs/max', () => ({ + useIntl: () => ({ + formatMessage: vi.fn(({ defaultMessage }) => defaultMessage), + }), + FormattedMessage: ({ defaultMessage }: { defaultMessage: string }) => ( + {defaultMessage} + ), +})); + +vi.mock('./service', () => ({ + fakeChartData: vi.fn(), +})); + +vi.mock('./style.style', () => ({ + default: () => ({ + styles: { + iconGroup: 'icon-group', + currentDate: 'current-date', + }, + }), +})); + +// Mock child components to avoid heavy rendering +vi.mock('./components/IntroduceRow', () => ({ + default: ({ loading, visitData }: any) => ( +
+ IntroduceRow - {loading ? 'loading' : `${visitData.length} items`} +
+ ), +})); + +vi.mock('./components/SalesCard', () => ({ + default: ({ loading, salesData }: any) => ( +
+ SalesCard - {loading ? 'loading' : `${salesData.length} items`} +
+ ), +})); + +vi.mock('./components/TopSearch', () => ({ + default: ({ loading, searchData }: any) => ( +
+ TopSearch - {loading ? 'loading' : `${searchData.length} items`} +
+ ), +})); + +vi.mock('./components/ProportionSales', () => ({ + default: ({ loading, salesPieData }: any) => ( +
+ ProportionSales -{' '} + {loading ? 'loading' : `${salesPieData?.length || 0} items`} +
+ ), +})); + +vi.mock('./components/OfflineData', () => ({ + default: ({ loading, offlineData }: any) => ( +
+ OfflineData - {loading ? 'loading' : `${offlineData.length} items`} +
+ ), +})); + +vi.mock('./components/PageLoading', () => ({ + default: () =>
Loading...
, +})); + +import Analysis from './index'; + +const mockAnalysisData: AnalysisData = { + visitData: [ + { x: '2024-01-01', y: 100 }, + { x: '2024-01-02', y: 200 }, + ], + visitData2: [ + { x: '2024-01-01', y: 50 }, + { x: '2024-01-02', y: 150 }, + ], + salesData: [ + { x: '2024-01', y: 1000 }, + { x: '2024-02', y: 2000 }, + ], + searchData: [ + { index: 1, keyword: 'test', count: 100, range: 10, status: 0 }, + { index: 2, keyword: 'search', count: 200, range: 20, status: 1 }, + ], + offlineData: [ + { + name: 'Store 1', + cvr: 0.85, + }, + { + name: 'Store 2', + cvr: 0.92, + }, + ], + offlineChartData: [ + { x: '00:00', y1: 10, y2: 20 }, + { x: '01:00', y1: 15, y2: 25 }, + ], + salesTypeData: [ + { x: 'Category A', y: 1000 }, + { x: 'Category B', y: 2000 }, + ], + salesTypeDataOnline: [ + { x: 'Online A', y: 500 }, + { x: 'Online B', y: 1500 }, + ], + salesTypeDataOffline: [ + { x: 'Offline A', y: 500 }, + { x: 'Offline B', y: 500 }, + ], + radarData: [ + { name: 'Personal', label: 'Quote', value: 10 }, + { name: 'Team', label: 'Quote', value: 30 }, + ], +}; + +describe('Analysis Dashboard', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + vi.clearAllMocks(); + }); + + it('should render without crashing', () => { + vi.mocked(service.fakeChartData).mockResolvedValue({ + data: mockAnalysisData, + }); + + const { container } = render( + + + , + ); + + expect(container).toBeTruthy(); + }); + + it('should render all dashboard components', async () => { + vi.mocked(service.fakeChartData).mockResolvedValue({ + data: mockAnalysisData, + }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('introduce-row')).toBeInTheDocument(); + expect(screen.getByTestId('sales-card')).toBeInTheDocument(); + expect(screen.getByTestId('top-search')).toBeInTheDocument(); + expect(screen.getByTestId('proportion-sales')).toBeInTheDocument(); + expect(screen.getByTestId('offline-data')).toBeInTheDocument(); + }); + }); + + it('should fetch chart data on mount', async () => { + vi.mocked(service.fakeChartData).mockResolvedValue({ + data: mockAnalysisData, + }); + + render( + + + , + ); + + await waitFor(() => { + expect(service.fakeChartData).toHaveBeenCalled(); + }); + }); + + it('should pass correct data to child components', async () => { + vi.mocked(service.fakeChartData).mockResolvedValue({ + data: mockAnalysisData, + }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('introduce-row')).toHaveTextContent('2 items'); + expect(screen.getByTestId('sales-card')).toHaveTextContent('2 items'); + }); + }); + + it('should handle empty data gracefully', async () => { + vi.mocked(service.fakeChartData).mockResolvedValue({ + data: { + visitData: [], + visitData2: [], + salesData: [], + searchData: [], + offlineData: [], + offlineChartData: [], + salesTypeData: [], + salesTypeDataOnline: [], + salesTypeDataOffline: [], + radarData: [], + }, + }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('introduce-row')).toHaveTextContent('0 items'); + }); + }); +}); diff --git a/src/pages/form/basic-form/index.test.tsx b/src/pages/form/basic-form/index.test.tsx new file mode 100644 index 00000000..f990e855 --- /dev/null +++ b/src/pages/form/basic-form/index.test.tsx @@ -0,0 +1,161 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as service from './service'; + +// Mock ProComponents +vi.mock('@ant-design/pro-components', () => ({ + PageContainer: ({ children, content }: any) => ( +
+ {content &&
{content}
} + {children} +
+ ), + ProForm: ({ children, onFinish }: any) => ( +
{ + e.preventDefault(); + onFinish?.(); + }} + > + {children} + +
+ ), + ProFormText: ({ label, name }: any) => ( +
{label}
+ ), + ProFormTextArea: ({ label, name }: any) => ( +
{label}
+ ), + ProFormDateRangePicker: ({ label, name }: any) => ( +
{label}
+ ), + ProFormDigit: ({ label, name }: any) => ( +
+ {typeof label === 'object' ? label.props.children[0] : label} +
+ ), + ProFormRadio: { + Group: ({ label }: any) => ( +
{label}
+ ), + }, + ProFormSelect: ({ name }: any) => ( +
Select
+ ), + ProFormDependency: ({ children }: any) => ( +
{children({ publicType: '2' })}
+ ), +})); + +// Mock dependencies +vi.mock('antd', async () => { + const actual = await vi.importActual('antd'); + return { + ...actual, + message: { + success: vi.fn(), + error: vi.fn(), + }, + Card: ({ children }: any) =>
{children}
, + }; +}); + +vi.mock('@umijs/max', () => ({ + useIntl: () => ({ + formatMessage: vi.fn(({ defaultMessage }) => defaultMessage), + }), + FormattedMessage: ({ defaultMessage }: { defaultMessage: string }) => ( + {defaultMessage} + ), +})); + +vi.mock('./service', () => ({ + fakeSubmitForm: vi.fn(), +})); + +vi.mock('./style.style', () => ({ + default: () => ({ + styles: { + optional: 'optional-class', + }, + }), +})); + +import BasicForm from './index'; + +describe('BasicForm', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + vi.clearAllMocks(); + }); + + it('should render without crashing', () => { + const { container } = render( + + + , + ); + + expect(container).toBeTruthy(); + }); + + it('should render form fields', () => { + render( + + + , + ); + + expect(screen.getByText('标题')).toBeInTheDocument(); + expect(screen.getByText('起止日期')).toBeInTheDocument(); + expect(screen.getByText('目标描述')).toBeInTheDocument(); + }); + + it('should have submit button', () => { + render( + + + , + ); + + expect(screen.getByRole('button', { name: /提交/i })).toBeInTheDocument(); + }); + + it('should call fakeSubmitForm on successful submission', async () => { + const mockSubmit = vi.mocked(service.fakeSubmitForm); + mockSubmit.mockResolvedValue({ success: true }); + + render( + + + , + ); + + const submitButton = screen.getByRole('button', { name: /提交/i }); + submitButton.click(); + + await waitFor(() => { + expect(mockSubmit).toHaveBeenCalled(); + }); + }); + + it('should render within PageContainer', () => { + render( + + + , + ); + + expect(screen.getByTestId('page-container')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/table-list/index.test.tsx b/src/pages/table-list/index.test.tsx new file mode 100644 index 00000000..1f8277b7 --- /dev/null +++ b/src/pages/table-list/index.test.tsx @@ -0,0 +1,180 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as api from '@/services/ant-design-pro/api'; + +// Mock ProComponents before importing component +vi.mock('@ant-design/pro-components', () => ({ + PageContainer: ({ children }: any) => ( +
{children}
+ ), + ProTable: ({ columns, toolBarRender, request }: any) => { + // Invoke request prop to simulate ProTable data loading + request?.({ current: 1, pageSize: 20 }, {}, {}); + return ( +
+
+ {columns?.map((col: any) => ( +
+ {typeof col.title === 'object' + ? col.title.props.defaultMessage + : col.title} +
+ ))} +
+ {toolBarRender &&
{toolBarRender()}
} +
+ ); + }, + FooterToolbar: ({ children }: any) => ( +
{children}
+ ), + ProDescriptions: ({ title }: any) => ( +
{title}
+ ), +})); + +// Mock dependencies +vi.mock('antd', async () => { + const actual = await vi.importActual('antd'); + return { + ...actual, + message: { + useMessage: () => [ + { + success: vi.fn(), + error: vi.fn(), + }, + null, + ], + success: vi.fn(), + error: vi.fn(), + }, + Drawer: ({ children, open }: any) => + open ?
{children}
: null, + Button: ({ children, onClick }: any) => ( + + ), + Input: (props: any) => , + }; +}); + +vi.mock('@umijs/max', () => ({ + useIntl: () => ({ + formatMessage: vi.fn(({ defaultMessage }) => defaultMessage), + }), + FormattedMessage: ({ defaultMessage }: { defaultMessage: string }) => ( + {defaultMessage} + ), +})); + +vi.mock('@/services/ant-design-pro/api', () => ({ + rule: vi.fn(), + removeRule: vi.fn(), + addRule: vi.fn(), + updateRule: vi.fn(), +})); + +vi.mock('./components/CreateForm', () => ({ + default: ({ trigger }: any) =>
{trigger}
, +})); + +vi.mock('./components/UpdateForm', () => ({ + default: ({ trigger }: any) =>
{trigger}
, +})); + +import TableList from './index'; + +describe('TableList', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + vi.clearAllMocks(); + + // Mock rule API to return empty data + vi.mocked(api.rule).mockResolvedValue({ + data: [], + total: 0, + success: true, + }); + }); + + it('should render without crashing', () => { + const { container } = render( + + + , + ); + + expect(container).toBeTruthy(); + }); + + it('should render ProTable component', () => { + render( + + + , + ); + + expect(screen.getByTestId('pro-table')).toBeInTheDocument(); + }); + + it('should have correct table columns', () => { + render( + + + , + ); + + expect(screen.getByText('Rule name')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + }); + + it('should call removeRule mutation successfully', async () => { + const mockRemoveRule = vi.mocked(api.removeRule); + mockRemoveRule.mockResolvedValue({ success: true }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('pro-table')).toBeInTheDocument(); + }); + }); + + it('should handle empty data state', async () => { + vi.mocked(api.rule).mockResolvedValue({ + data: [], + total: 0, + success: true, + }); + + render( + + + , + ); + + await waitFor(() => { + expect(api.rule).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/requestErrorConfig.test.ts b/src/requestErrorConfig.test.ts new file mode 100644 index 00000000..28a6f352 --- /dev/null +++ b/src/requestErrorConfig.test.ts @@ -0,0 +1,268 @@ +import { message, notification } from 'antd'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { errorConfig } from './requestErrorConfig'; + +vi.mock('antd', () => ({ + message: { + warning: vi.fn(), + error: vi.fn(), + }, + notification: { + open: vi.fn(), + }, +})); + +vi.mock('@umijs/max', () => ({ + getIntl: vi.fn(() => ({ + formatMessage: vi.fn(({ defaultMessage }) => defaultMessage), + })), +})); + +describe('requestErrorConfig', () => { + // biome-ignore lint/style/noNonNullAssertion: config handlers are always defined + const errorThrower = errorConfig.errorConfig!.errorThrower!; + // biome-ignore lint/style/noNonNullAssertion: config handlers are always defined + const errorHandler = errorConfig.errorConfig!.errorHandler!; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('errorThrower', () => { + it('should throw error when success is false', () => { + const response = { + success: false, + data: null, + errorCode: 400, + errorMessage: 'Bad Request', + showType: 2, + }; + + expect(() => { + errorThrower(response); + }).toThrow('Bad Request'); + }); + + it('should not throw error when success is true', () => { + const response = { + success: true, + data: { id: 1 }, + }; + + expect(() => { + errorThrower(response); + }).not.toThrow(); + }); + + it('should throw BizError with correct info', () => { + const response = { + success: false, + data: { detail: 'more info' }, + errorCode: 403, + errorMessage: 'Forbidden', + showType: 3, + }; + + expect.assertions(5); + try { + errorThrower(response); + } catch (error: any) { + expect(error.name).toBe('BizError'); + expect(error.info.errorCode).toBe(403); + expect(error.info.errorMessage).toBe('Forbidden'); + expect(error.info.showType).toBe(3); + expect(error.info.data).toEqual({ detail: 'more info' }); + } + }); + }); + + describe('errorHandler', () => { + it('should rethrow error when skipErrorHandler is true', () => { + const error = new Error('Test error'); + const opts = { skipErrorHandler: true }; + + expect(() => { + errorHandler(error, opts); + }).toThrow('Test error'); + }); + + it('should handle SILENT showType', () => { + const error: any = new Error('Silent error'); + error.name = 'BizError'; + error.info = { + errorCode: 1001, + errorMessage: 'Silent error', + showType: 0, + }; + + errorHandler(error, {}); + + expect(message.warning).not.toHaveBeenCalled(); + expect(message.error).not.toHaveBeenCalled(); + expect(notification.open).not.toHaveBeenCalled(); + }); + + it('should handle WARN_MESSAGE showType', () => { + const error: any = new Error('Warning'); + error.name = 'BizError'; + error.info = { + errorCode: 1002, + errorMessage: 'This is a warning', + showType: 1, + }; + + errorHandler(error, {}); + + expect(message.warning).toHaveBeenCalledWith('This is a warning'); + }); + + it('should handle ERROR_MESSAGE showType', () => { + const error: any = new Error('Error message'); + error.name = 'BizError'; + error.info = { + errorCode: 1003, + errorMessage: 'This is an error', + showType: 2, + }; + + errorHandler(error, {}); + + expect(message.error).toHaveBeenCalledWith('This is an error'); + }); + + it('should handle NOTIFICATION showType', () => { + const error: any = new Error('Notification'); + error.name = 'BizError'; + error.info = { + errorCode: 1004, + errorMessage: 'This is a notification', + showType: 3, + }; + + errorHandler(error, {}); + + expect(notification.open).toHaveBeenCalledWith({ + title: 1004, + description: 'This is a notification', + }); + }); + + it('should handle REDIRECT showType', () => { + const error: any = new Error('Redirect'); + error.name = 'BizError'; + error.info = { + errorCode: 401, + errorMessage: 'Unauthorized', + showType: 9, + }; + + errorHandler(error, {}); + + // REDIRECT 分支不应触发任何消息/通知提示 + expect(message.warning).not.toHaveBeenCalled(); + expect(message.error).not.toHaveBeenCalled(); + expect(notification.open).not.toHaveBeenCalled(); + }); + + it('should handle default case for unknown showType', () => { + const error: any = new Error('Unknown type'); + error.name = 'BizError'; + error.info = { + errorCode: 1005, + errorMessage: 'Unknown error type', + showType: 99, + }; + + errorHandler(error, {}); + + expect(message.error).toHaveBeenCalledWith('Unknown error type'); + }); + + it('should handle axios response error', () => { + const error: any = new Error('Axios error'); + error.response = { + status: 500, + data: {}, + }; + + errorHandler(error, {}); + + expect(message.error).toHaveBeenCalledWith('Response status:500'); + }); + + it('should handle offline error', () => { + const error: any = new Error('Network error'); + error.request = {}; + + const originalOnLine = navigator.onLine; + Object.defineProperty(navigator, 'onLine', { + writable: true, + value: false, + }); + + try { + errorHandler(error, {}); + + expect(message.error).toHaveBeenCalledWith( + 'Network unavailable. Please check your connection and try again.', + ); + } finally { + Object.defineProperty(navigator, 'onLine', { + writable: true, + value: originalOnLine, + }); + } + }); + + it('should handle request error with no response', () => { + const error: any = new Error('Request error'); + error.request = {}; + + errorHandler(error, {}); + + expect(message.error).toHaveBeenCalledWith( + 'None response! Please retry.', + ); + }); + + it('should handle generic error', () => { + const error: any = new Error('Generic error'); + + errorHandler(error, {}); + + expect(message.error).toHaveBeenCalledWith( + 'Request error, please retry.', + ); + }); + }); + + describe('requestInterceptors', () => { + // The interceptor is registered as a plain function (not a tuple), + // so narrow the union type to a callable for the test. + const interceptor = errorConfig.requestInterceptors?.[0] as (config: { + url?: string; + method?: string; + }) => { url?: string }; + + it('should pass through config without modification', () => { + const config = { + url: 'https://api.example.com/users', + method: 'GET', + }; + + const result = interceptor(config); + + // Token attachment is intentionally commented out in the source; + // interceptor currently returns config as-is + expect(result.url).toBe('https://api.example.com/users'); + }); + + it('should handle URL without config', () => { + const config = {}; + + const result = interceptor(config); + + expect(result.url).toBeUndefined(); + }); + }); +}); diff --git a/tests/setupTests.ts b/tests/setupTests.ts index 8e23cf22..f4a08ecd 100644 --- a/tests/setupTests.ts +++ b/tests/setupTests.ts @@ -1,3 +1,4 @@ +import '@testing-library/jest-dom/vitest'; import { vi } from 'vitest'; const store: Record = {};