diff --git a/frontend/src/api/auth.test.ts b/frontend/src/api/auth.test.ts new file mode 100644 index 0000000..a5efcc1 --- /dev/null +++ b/frontend/src/api/auth.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { authApi } from './auth'; +import { BizError } from './errors'; + +describe('authApi.login', () => { + it('returns LoginVo on success', async () => { + const vo = await authApi.login({ + username: 'alice', + password: 'Password1!', + companyCode: 'HQ', + }); + expect(vo.accessToken).toBe('fake-jwt'); + expect(vo.userInfo.username).toBe('alice'); + expect(vo.userInfo.employeeName).toBe('张三'); + expect(vo.expiresInSec).toBe(7200); + }); + + it('throws BizError 40101 on bad credentials', async () => { + await expect( + authApi.login({ username: 'alice', password: 'WRONG', companyCode: 'HQ' }), + ).rejects.toMatchObject({ code: 40101 }); + }); + + it('throws BizError 42301 with lockUntil data on locked account', async () => { + try { + await authApi.login({ username: 'locked', password: 'X', companyCode: 'HQ' }); + throw new Error('expected throw'); + } catch (e) { + expect(e).toBeInstanceOf(BizError); + const be = e as BizError; + expect(be.code).toBe(42301); + expect((be.data as { lockUntil: string }).lockUntil).toBe('2030-01-01T12:00:00'); + } + }); + + it('throws BizError 40103 on deleted account', async () => { + await expect( + authApi.login({ username: 'deleted', password: 'X', companyCode: 'HQ' }), + ).rejects.toMatchObject({ code: 40103 }); + }); + + it('throws BizError 40004 on unknown company', async () => { + await expect( + authApi.login({ username: 'alice', password: 'Password1!', companyCode: 'NOPE' }), + ).rejects.toMatchObject({ code: 40004 }); + }); +}); diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..42615f5 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,29 @@ +import { apiClient } from './client'; + +export interface LoginReq { + username: string; + password: string; + companyCode: string; +} + +export interface UserInfo { + userId: number; + username: string; + userType: 'NORMAL' | 'SUPER_ADMIN'; + language: string; + employeeName?: string; + companyCode: string; +} + +export interface LoginVo { + accessToken: string; + tokenType: 'Bearer'; + expiresInSec: number; + userInfo: UserInfo; +} + +export const authApi = { + async login(req: LoginReq): Promise { + return (await apiClient.post('/auth/login', req)); + }, +}; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..1760ae3 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,49 @@ +import axios, { AxiosError, AxiosResponse } from 'axios'; +import { BizError } from './errors'; + +let getAccessToken: () => string | null = () => null; + +/** + * 注册 token 提供者。Redux store 初始化后由 store/index.ts 调用, + * 把 store.getState().auth.accessToken 接进来。 + * 避免直接 import store 形成循环依赖。 + */ +export function registerAccessTokenProvider(fn: () => string | null) { + getAccessToken = fn; +} + +export const apiClient = axios.create({ + baseURL: (import.meta as any).env?.VITE_API_BASE_URL ?? '/api/v1', + timeout: 10000, +}); + +apiClient.interceptors.request.use((config) => { + const token = getAccessToken(); + if (token) { + config.headers.set('Authorization', `Bearer ${token}`); + } + return config; +}); + +apiClient.interceptors.response.use( + (response: AxiosResponse) => { + const body = response.data; + if (body && typeof body === 'object' && 'code' in body) { + if (body.code === 200) { + return body.data; + } + throw new BizError(body.code, body.message ?? '业务错误', body.data); + } + return body; + }, + (error: AxiosError) => { + if (error.response) { + const body = error.response.data as { code?: number; message?: string; data?: unknown } | undefined; + if (body && typeof body === 'object' && 'code' in body) { + throw new BizError(body.code!, body.message ?? '请求失败', body.data); + } + throw new BizError(error.response.status, error.response.statusText ?? 'HTTP error'); + } + throw new BizError(-1, 'NETWORK'); + }, +); diff --git a/frontend/src/api/errors.ts b/frontend/src/api/errors.ts new file mode 100644 index 0000000..d1a8462 --- /dev/null +++ b/frontend/src/api/errors.ts @@ -0,0 +1,15 @@ +export class BizError extends Error { + code: number; + data?: unknown; + + constructor(code: number, message: string, data?: unknown) { + super(message); + this.name = 'BizError'; + this.code = code; + this.data = data; + } +} + +export function isBizError(e: unknown): e is BizError { + return e instanceof BizError; +} diff --git a/frontend/src/test-utils/msw-handlers.ts b/frontend/src/test-utils/msw-handlers.ts new file mode 100644 index 0000000..20bd633 --- /dev/null +++ b/frontend/src/test-utils/msw-handlers.ts @@ -0,0 +1,67 @@ +import { http, HttpResponse, delay } from 'msw'; + +const BASE = '/api/v1'; + +export const handlers = [ + http.post(`${BASE}/auth/login`, async ({ request }) => { + const body = (await request.json()) as { username: string; password: string; companyCode: string }; + + if (body.companyCode === 'NOPE') { + return HttpResponse.json( + { code: 40004, message: '公司不存在或已删除', data: null, timestamp: Date.now() }, + { status: 400 }, + ); + } + if (body.username === 'locked') { + return HttpResponse.json( + { + code: 42301, + message: '账号已锁定,请稍后再试', + data: { lockUntil: '2030-01-01T12:00:00' }, + timestamp: Date.now(), + }, + { status: 423 }, + ); + } + if (body.username === 'deleted') { + return HttpResponse.json( + { code: 40103, message: '账号已被作废,禁止登录', data: null, timestamp: Date.now() }, + { status: 401 }, + ); + } + if (body.username !== 'alice' || body.password !== 'Password1!') { + return HttpResponse.json( + { code: 40101, message: '用户名或密码错误', data: null, timestamp: Date.now() }, + { status: 401 }, + ); + } + + return HttpResponse.json( + { + code: 200, + message: '操作成功', + data: { + accessToken: 'fake-jwt', + tokenType: 'Bearer', + expiresInSec: 7200, + userInfo: { + userId: 1, + username: 'alice', + userType: 'NORMAL', + language: 'zh-CN', + employeeName: '张三', + companyCode: body.companyCode, + }, + }, + timestamp: Date.now(), + }, + { status: 200 }, + ); + }), + + // Generic network-error stub for tests that pass requestUrl = "/network-error" + http.post(`${BASE}/network-error`, async () => { + await delay(50); + return HttpResponse.error(); + }), +]; diff --git a/frontend/src/test-utils/setup.ts b/frontend/src/test-utils/setup.ts index bb02c60..3c7efff 100644 --- a/frontend/src/test-utils/setup.ts +++ b/frontend/src/test-utils/setup.ts @@ -1 +1,10 @@ import '@testing-library/jest-dom/vitest'; +import { afterAll, afterEach, beforeAll } from 'vitest'; +import { setupServer } from 'msw/node'; +import { handlers } from './msw-handlers'; + +export const server = setupServer(...handlers); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close());