Initial commit
This commit is contained in:
199
api/middlewares/__tests__/errorHandler.test.ts
Normal file
199
api/middlewares/__tests__/errorHandler.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
const { mockIsAppError, mockIsNodeError, mockLoggerError, MockAppError, MockValidationError } = vi.hoisted(() => {
|
||||
const mockFn = () => ({})
|
||||
return {
|
||||
mockIsAppError: vi.fn(),
|
||||
mockIsNodeError: vi.fn(),
|
||||
mockLoggerError: vi.fn(),
|
||||
MockAppError: class MockAppError extends Error {
|
||||
statusCode: number
|
||||
details?: Record<string, unknown>
|
||||
code: string
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
statusCode: number = 500,
|
||||
details?: Record<string, unknown>
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'MockAppError'
|
||||
this.code = code
|
||||
this.statusCode = statusCode
|
||||
this.details = details
|
||||
}
|
||||
},
|
||||
MockValidationError: class MockValidationError extends Error {
|
||||
statusCode: number
|
||||
details?: Record<string, unknown>
|
||||
code: string
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super(message)
|
||||
this.name = 'MockValidationError'
|
||||
this.code = 'VALIDATION_ERROR'
|
||||
this.statusCode = 400
|
||||
this.details = details
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@shared/errors', () => ({
|
||||
isAppError: mockIsAppError,
|
||||
isNodeError: mockIsNodeError,
|
||||
AppError: MockAppError,
|
||||
ValidationError: MockValidationError,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/utils/logger', () => ({
|
||||
logger: {
|
||||
error: mockLoggerError,
|
||||
},
|
||||
}))
|
||||
|
||||
import { errorHandler } from '../errorHandler'
|
||||
|
||||
describe('errorHandler', () => {
|
||||
let mockReq: Request
|
||||
let mockRes: Response
|
||||
let mockNext: NextFunction
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockReq = {} as Request
|
||||
mockRes = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
} as unknown as Response
|
||||
mockNext = vi.fn()
|
||||
mockIsAppError.mockReturnValue(false)
|
||||
mockIsNodeError.mockReturnValue(false)
|
||||
})
|
||||
|
||||
describe('AppError 处理', () => {
|
||||
it('AppError 应发送自定义状态码和错误码', () => {
|
||||
const appError = new MockAppError('VALIDATION_ERROR', '验证失败', 400, { field: 'name' })
|
||||
mockIsAppError.mockReturnValue(true)
|
||||
|
||||
errorHandler(appError, mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: '验证失败',
|
||||
details: { field: 'name' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('ValidationError 应发送正确的错误信息', () => {
|
||||
const validationError = new MockValidationError('字段不能为空', { field: 'email' })
|
||||
mockIsAppError.mockReturnValue(true)
|
||||
|
||||
errorHandler(validationError, mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: '字段不能为空',
|
||||
details: { field: 'email' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('AppError 在生产环境不应包含 details', () => {
|
||||
const originalEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
try {
|
||||
const appError = new MockAppError('VALIDATION_ERROR', '验证失败', 400, { field: 'name' })
|
||||
mockIsAppError.mockReturnValue(true)
|
||||
|
||||
errorHandler(appError, mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: '验证失败',
|
||||
details: undefined,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalEnv
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node.js 系统错误处理', () => {
|
||||
it('Node.js 系统错误应包含 stack 信息(非生产环境)', () => {
|
||||
const nodeError = new Error('系统错误') as NodeJS.ErrnoException
|
||||
nodeError.code = 'ENOENT'
|
||||
nodeError.stack = 'Error: 系统错误\n at Test.<anonymous>'
|
||||
|
||||
mockIsAppError.mockReturnValue(false)
|
||||
mockIsNodeError.mockReturnValue(true)
|
||||
|
||||
errorHandler(nodeError, mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: '系统错误',
|
||||
details: {
|
||||
stack: nodeError.stack,
|
||||
nodeErrorCode: 'ENOENT',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('普通 Error 应包含 stack 信息(非生产环境)', () => {
|
||||
const error = new Error('普通错误')
|
||||
error.stack = 'Error: 普通错误\n at Test.<anonymous>'
|
||||
|
||||
errorHandler(error, mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: '普通错误',
|
||||
details: {
|
||||
stack: error.stack,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('在生产环境不包含敏感信息', () => {
|
||||
const originalEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
try {
|
||||
const error = new Error('错误信息')
|
||||
error.stack = 'Error: 错误信息\n at Test.<anonymous>'
|
||||
|
||||
errorHandler(error, mockReq, mockRes, mockNext)
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: '错误信息',
|
||||
details: undefined,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalEnv
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
42
api/middlewares/errorHandler.ts
Normal file
42
api/middlewares/errorHandler.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import type { ApiResponse } from '../../shared/types.js'
|
||||
import { ERROR_CODES } from '../../shared/constants/errors.js'
|
||||
import { isAppError, isNodeError } from '../../shared/errors/index.js'
|
||||
import { logger } from '../utils/logger.js'
|
||||
|
||||
export const errorHandler = (err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
let statusCode: number = 500
|
||||
let code: string = ERROR_CODES.INTERNAL_ERROR
|
||||
let message: string = 'Server internal error'
|
||||
let details: unknown = undefined
|
||||
|
||||
if (isAppError(err)) {
|
||||
statusCode = err.statusCode
|
||||
code = err.code
|
||||
message = err.message
|
||||
details = err.details
|
||||
} else if (isNodeError(err)) {
|
||||
message = err.message
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
details = { stack: err.stack, nodeErrorCode: err.code }
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
message = err.message
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
details = { stack: err.stack }
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(err)
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
details: process.env.NODE_ENV === 'production' ? undefined : details,
|
||||
},
|
||||
}
|
||||
|
||||
res.status(statusCode).json(response)
|
||||
}
|
||||
33
api/middlewares/validate.ts
Normal file
33
api/middlewares/validate.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { ZodSchema, ZodError } from 'zod'
|
||||
import { ValidationError } from '../../shared/errors/index.js'
|
||||
|
||||
export const validateBody = (schema: ZodSchema) => {
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
try {
|
||||
req.body = schema.parse(req.body)
|
||||
next()
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
next(new ValidationError('Request validation failed', { issues: error.issues }))
|
||||
} else {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const validateQuery = (schema: ZodSchema) => {
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
try {
|
||||
req.query = schema.parse(req.query) as typeof req.query
|
||||
next()
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
next(new ValidationError('Query validation failed', { issues: error.issues }))
|
||||
} else {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user