Commit 13ef1e2fc19829f94bf1e114c04013547738245a
1 parent
58186b44
feat(frontend): axios client + authApi.login + MSW handlers
REQ_ID: FE-01
Showing
6 changed files
with
216 additions
and
0 deletions
frontend/src/api/auth.test.ts
0 → 100644
| 1 | +import { describe, it, expect } from 'vitest'; | ||
| 2 | +import { authApi } from './auth'; | ||
| 3 | +import { BizError } from './errors'; | ||
| 4 | + | ||
| 5 | +describe('authApi.login', () => { | ||
| 6 | + it('returns LoginVo on success', async () => { | ||
| 7 | + const vo = await authApi.login({ | ||
| 8 | + username: 'alice', | ||
| 9 | + password: 'Password1!', | ||
| 10 | + companyCode: 'HQ', | ||
| 11 | + }); | ||
| 12 | + expect(vo.accessToken).toBe('fake-jwt'); | ||
| 13 | + expect(vo.userInfo.username).toBe('alice'); | ||
| 14 | + expect(vo.userInfo.employeeName).toBe('张三'); | ||
| 15 | + expect(vo.expiresInSec).toBe(7200); | ||
| 16 | + }); | ||
| 17 | + | ||
| 18 | + it('throws BizError 40101 on bad credentials', async () => { | ||
| 19 | + await expect( | ||
| 20 | + authApi.login({ username: 'alice', password: 'WRONG', companyCode: 'HQ' }), | ||
| 21 | + ).rejects.toMatchObject({ code: 40101 }); | ||
| 22 | + }); | ||
| 23 | + | ||
| 24 | + it('throws BizError 42301 with lockUntil data on locked account', async () => { | ||
| 25 | + try { | ||
| 26 | + await authApi.login({ username: 'locked', password: 'X', companyCode: 'HQ' }); | ||
| 27 | + throw new Error('expected throw'); | ||
| 28 | + } catch (e) { | ||
| 29 | + expect(e).toBeInstanceOf(BizError); | ||
| 30 | + const be = e as BizError; | ||
| 31 | + expect(be.code).toBe(42301); | ||
| 32 | + expect((be.data as { lockUntil: string }).lockUntil).toBe('2030-01-01T12:00:00'); | ||
| 33 | + } | ||
| 34 | + }); | ||
| 35 | + | ||
| 36 | + it('throws BizError 40103 on deleted account', async () => { | ||
| 37 | + await expect( | ||
| 38 | + authApi.login({ username: 'deleted', password: 'X', companyCode: 'HQ' }), | ||
| 39 | + ).rejects.toMatchObject({ code: 40103 }); | ||
| 40 | + }); | ||
| 41 | + | ||
| 42 | + it('throws BizError 40004 on unknown company', async () => { | ||
| 43 | + await expect( | ||
| 44 | + authApi.login({ username: 'alice', password: 'Password1!', companyCode: 'NOPE' }), | ||
| 45 | + ).rejects.toMatchObject({ code: 40004 }); | ||
| 46 | + }); | ||
| 47 | +}); |
frontend/src/api/auth.ts
0 → 100644
| 1 | +import { apiClient } from './client'; | ||
| 2 | + | ||
| 3 | +export interface LoginReq { | ||
| 4 | + username: string; | ||
| 5 | + password: string; | ||
| 6 | + companyCode: string; | ||
| 7 | +} | ||
| 8 | + | ||
| 9 | +export interface UserInfo { | ||
| 10 | + userId: number; | ||
| 11 | + username: string; | ||
| 12 | + userType: 'NORMAL' | 'SUPER_ADMIN'; | ||
| 13 | + language: string; | ||
| 14 | + employeeName?: string; | ||
| 15 | + companyCode: string; | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +export interface LoginVo { | ||
| 19 | + accessToken: string; | ||
| 20 | + tokenType: 'Bearer'; | ||
| 21 | + expiresInSec: number; | ||
| 22 | + userInfo: UserInfo; | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +export const authApi = { | ||
| 26 | + async login(req: LoginReq): Promise<LoginVo> { | ||
| 27 | + return (await apiClient.post<unknown, LoginVo>('/auth/login', req)); | ||
| 28 | + }, | ||
| 29 | +}; |
frontend/src/api/client.ts
0 → 100644
| 1 | +import axios, { AxiosError, AxiosResponse } from 'axios'; | ||
| 2 | +import { BizError } from './errors'; | ||
| 3 | + | ||
| 4 | +let getAccessToken: () => string | null = () => null; | ||
| 5 | + | ||
| 6 | +/** | ||
| 7 | + * 注册 token 提供者。Redux store 初始化后由 store/index.ts 调用, | ||
| 8 | + * 把 store.getState().auth.accessToken 接进来。 | ||
| 9 | + * 避免直接 import store 形成循环依赖。 | ||
| 10 | + */ | ||
| 11 | +export function registerAccessTokenProvider(fn: () => string | null) { | ||
| 12 | + getAccessToken = fn; | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +export const apiClient = axios.create({ | ||
| 16 | + baseURL: (import.meta as any).env?.VITE_API_BASE_URL ?? '/api/v1', | ||
| 17 | + timeout: 10000, | ||
| 18 | +}); | ||
| 19 | + | ||
| 20 | +apiClient.interceptors.request.use((config) => { | ||
| 21 | + const token = getAccessToken(); | ||
| 22 | + if (token) { | ||
| 23 | + config.headers.set('Authorization', `Bearer ${token}`); | ||
| 24 | + } | ||
| 25 | + return config; | ||
| 26 | +}); | ||
| 27 | + | ||
| 28 | +apiClient.interceptors.response.use( | ||
| 29 | + (response: AxiosResponse) => { | ||
| 30 | + const body = response.data; | ||
| 31 | + if (body && typeof body === 'object' && 'code' in body) { | ||
| 32 | + if (body.code === 200) { | ||
| 33 | + return body.data; | ||
| 34 | + } | ||
| 35 | + throw new BizError(body.code, body.message ?? '业务错误', body.data); | ||
| 36 | + } | ||
| 37 | + return body; | ||
| 38 | + }, | ||
| 39 | + (error: AxiosError) => { | ||
| 40 | + if (error.response) { | ||
| 41 | + const body = error.response.data as { code?: number; message?: string; data?: unknown } | undefined; | ||
| 42 | + if (body && typeof body === 'object' && 'code' in body) { | ||
| 43 | + throw new BizError(body.code!, body.message ?? '请求失败', body.data); | ||
| 44 | + } | ||
| 45 | + throw new BizError(error.response.status, error.response.statusText ?? 'HTTP error'); | ||
| 46 | + } | ||
| 47 | + throw new BizError(-1, 'NETWORK'); | ||
| 48 | + }, | ||
| 49 | +); |
frontend/src/api/errors.ts
0 → 100644
| 1 | +export class BizError extends Error { | ||
| 2 | + code: number; | ||
| 3 | + data?: unknown; | ||
| 4 | + | ||
| 5 | + constructor(code: number, message: string, data?: unknown) { | ||
| 6 | + super(message); | ||
| 7 | + this.name = 'BizError'; | ||
| 8 | + this.code = code; | ||
| 9 | + this.data = data; | ||
| 10 | + } | ||
| 11 | +} | ||
| 12 | + | ||
| 13 | +export function isBizError(e: unknown): e is BizError { | ||
| 14 | + return e instanceof BizError; | ||
| 15 | +} |
frontend/src/test-utils/msw-handlers.ts
0 → 100644
| 1 | +import { http, HttpResponse, delay } from 'msw'; | ||
| 2 | + | ||
| 3 | +const BASE = '/api/v1'; | ||
| 4 | + | ||
| 5 | +export const handlers = [ | ||
| 6 | + http.post(`${BASE}/auth/login`, async ({ request }) => { | ||
| 7 | + const body = (await request.json()) as { username: string; password: string; companyCode: string }; | ||
| 8 | + | ||
| 9 | + if (body.companyCode === 'NOPE') { | ||
| 10 | + return HttpResponse.json( | ||
| 11 | + { code: 40004, message: '公司不存在或已删除', data: null, timestamp: Date.now() }, | ||
| 12 | + { status: 400 }, | ||
| 13 | + ); | ||
| 14 | + } | ||
| 15 | + if (body.username === 'locked') { | ||
| 16 | + return HttpResponse.json( | ||
| 17 | + { | ||
| 18 | + code: 42301, | ||
| 19 | + message: '账号已锁定,请稍后再试', | ||
| 20 | + data: { lockUntil: '2030-01-01T12:00:00' }, | ||
| 21 | + timestamp: Date.now(), | ||
| 22 | + }, | ||
| 23 | + { status: 423 }, | ||
| 24 | + ); | ||
| 25 | + } | ||
| 26 | + if (body.username === 'deleted') { | ||
| 27 | + return HttpResponse.json( | ||
| 28 | + { code: 40103, message: '账号已被作废,禁止登录', data: null, timestamp: Date.now() }, | ||
| 29 | + { status: 401 }, | ||
| 30 | + ); | ||
| 31 | + } | ||
| 32 | + if (body.username !== 'alice' || body.password !== 'Password1!') { | ||
| 33 | + return HttpResponse.json( | ||
| 34 | + { code: 40101, message: '用户名或密码错误', data: null, timestamp: Date.now() }, | ||
| 35 | + { status: 401 }, | ||
| 36 | + ); | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + return HttpResponse.json( | ||
| 40 | + { | ||
| 41 | + code: 200, | ||
| 42 | + message: '操作成功', | ||
| 43 | + data: { | ||
| 44 | + accessToken: 'fake-jwt', | ||
| 45 | + tokenType: 'Bearer', | ||
| 46 | + expiresInSec: 7200, | ||
| 47 | + userInfo: { | ||
| 48 | + userId: 1, | ||
| 49 | + username: 'alice', | ||
| 50 | + userType: 'NORMAL', | ||
| 51 | + language: 'zh-CN', | ||
| 52 | + employeeName: '张三', | ||
| 53 | + companyCode: body.companyCode, | ||
| 54 | + }, | ||
| 55 | + }, | ||
| 56 | + timestamp: Date.now(), | ||
| 57 | + }, | ||
| 58 | + { status: 200 }, | ||
| 59 | + ); | ||
| 60 | + }), | ||
| 61 | + | ||
| 62 | + // Generic network-error stub for tests that pass requestUrl = "/network-error" | ||
| 63 | + http.post(`${BASE}/network-error`, async () => { | ||
| 64 | + await delay(50); | ||
| 65 | + return HttpResponse.error(); | ||
| 66 | + }), | ||
| 67 | +]; |
frontend/src/test-utils/setup.ts
| 1 | import '@testing-library/jest-dom/vitest'; | 1 | import '@testing-library/jest-dom/vitest'; |
| 2 | +import { afterAll, afterEach, beforeAll } from 'vitest'; | ||
| 3 | +import { setupServer } from 'msw/node'; | ||
| 4 | +import { handlers } from './msw-handlers'; | ||
| 5 | + | ||
| 6 | +export const server = setupServer(...handlers); | ||
| 7 | + | ||
| 8 | +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); | ||
| 9 | +afterEach(() => server.resetHandlers()); | ||
| 10 | +afterAll(() => server.close()); |