Commit 13ef1e2fc19829f94bf1e114c04013547738245a

Authored by zichun
1 parent 58186b44

feat(frontend): axios client + authApi.login + MSW handlers

REQ_ID: FE-01
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 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());
... ...