Настройка Express 5 для продакшна в 2025 году. Часть 2
- понедельник, 13 октября 2025 г. в 00:00:05
Эта статья поможет вам создать приложение Express 5 с поддержкой TypeScript.
Вы настроите готовый к продакшну проект с помощью различных инструментов для линтинга, тестирования и проверки типов. В случае, если вы новичок в REST API, не волнуйтесь, эта статья также включает объяснения основных концепций, которые следует знать, таких как маршрутизация (роутинг) и аутентификация.
Настоятельно рекомендую писать код вместе со мной. Мы будем использовать подход "Разработка через тестирование" (test-driven development, TDD) для создания REST API, который может стать основой вашего следующего приложения Express.
Прим. пер.: в коде оригинальной статьи встречаются устаревшие (deprecated) свойства и методы. Также некоторые оригинальные тесты работают нестабильно. Я позволил себе внести необходимые коррективы. Однако, учитывая объем материала, я вполне мог что-то упустить, поэтому вот ссылка на мой вариант полностью работоспособного кода приложения.
Это вторая часть туториала.
Большинству приложений требуется какая-то форма аутентификации. В этом туториале мы будем использовать токены JWT в куки для аутентификации запросов. Пользователи будут использовать классическую комбинацию email и пароля для аутентификации.
Однако, важно отметить, что пароли устарели. Перестаньте собирать и хранить пароли. Пароли являются слабыми, поскольку они могут копироваться, похищаться и взламываться грубой силой (brute force). Используйте passkeys для сильного логина и email OTP (one-time password - одноразовый пароль) только в качестве резерва.
Прим. пер.: у passkeys есть свои недостатки, поэтому утверждение о том, что пароли устарели, является довольно спорным.
Все, что вы узнаете об обработке токенов JWT и куки будет полезным, независимо от метода аутентификации.
Аутентификация будет работать через куки. При регистрации или входе в систему пользователя, куки добавляется в ответ, отправляемый клиенту. Браузер автоматически отправляет куки в каждом запросе к серверу. Сервер читает куки и использует их для аутентификации пользователя. Кроме этого, роут register
будет создавать профиль пользователя и сохранять его в БД. Роут logout
будет отправлять ответ, указывающий браузеру удалить куки.
Начнем с роута login
. Создаем файл src/features/user-authentication/user-authentication.test.ts
:
import { createId } from '@paralleldrive/cuid2';
import request from 'supertest';
import { describe, expect, onTestFinished, test } from 'vitest';
import { buildApp } from '~/app.js';
import { createPopulatedUserProfile } from '../user-profile/user-profile-factories.js';
import {
deleteUserProfileFromDatabaseById,
saveUserProfileToDatabase,
} from '../user-profile/user-profile-model.js';
import { hashPassword } from './user-authentication-helpers.js';
async function setup({ password = 'password' }: { password?: string } = {}) {
const app = buildApp();
const userProfile = await saveUserProfileToDatabase(
createPopulatedUserProfile({
hashedPassword: await hashPassword(password),
}),
);
onTestFinished(async () => {
await deleteUserProfileFromDatabaseById(userProfile.id);
});
return { app, userProfile };
}
describe('/api/v1/login', () => {
test('дано: валидные данные существующего пользователя, ожидается: возврат статуса 200 и установка JWT куки', async () => {
const password = createId();
const { app, userProfile } = await setup({ password });
const actual = await request(app)
.post('/api/v1/login')
.send({ email: userProfile.email, password })
.expect(200);
expect(actual.body).toEqual({ message: 'Logged in successfully' });
// Проверяем установку HTTP-only куки. По каким-то причинам
// supertest типизирует куки как строку, хотя это массив
const cookies = actual.headers['set-cookie'] as unknown as string[];
expect(cookies).toBeDefined();
expect(cookies.some(cookie => cookie.includes('jwt='))).toEqual(true);
});
test('дано: валидные данные несуществующего пользователя, ожидается: возврат статуса 401', async () => {
const { app } = await setup();
const { body: actual } = await request(app)
.post('/api/v1/login')
.send({ email: 'non-existing@test.com', password: 'password' })
.expect(401);
const expected = { message: 'Invalid credentials' };
expect(actual).toEqual(expected);
});
test('дано: валидный email, но неверный пароль существующего пользователя, ожидается: возврат статуса 401', async () => {
const { app, userProfile } = await setup();
const actual = await request(app)
.post('/api/v1/login')
.send({ email: userProfile.email, password: 'invalid password' })
.expect(401);
expect(actual.body).toEqual({ message: 'Invalid credentials' });
});
test('дано: невалидные данные, ожидается: возврат статуса 400', async () => {
const { app } = await setup();
const { body: actual } = await request(app)
.post('/api/v1/login')
.send({})
.expect(400);
const expected = {
message: 'Bad Request',
errors: [
{
code: 'invalid_type',
expected: 'string',
message: 'Invalid input: expected string, received undefined',
path: ['email'],
},
{
code: 'invalid_type',
expected: 'string',
message: 'Invalid input: expected string, received undefined',
path: ['password'],
},
],
};
expect(actual).toEqual(expected);
});
});
Сначала мы определяем функцию setup
, которая создает приложение и пользователя с хэшированным паролем и записывает его в БД. Обработчик onTestFinished
удаляет профиль пользователя после завершения тестов.
Функции hashPassword
пока нет, но скоро мы ее создадим. Как правило, при TDD нормальной практикой считается использование несуществующих функций, поскольку к ним также можно рекурсивно применить TDD. Обычно, сначала создается пустая версия, чтобы проходили импорты, а уже затем реализуется поведение.
Затем определяется тест для роута /api/v1/login
.
Сначала тестируется счастливый путь (happy path), когда пользователь существует и данные являются валидными.
Затем обрабатывается 4 тест-кейса:
Валидные данные существующего пользователя.
Валидные данные несуществующего пользователя.
Неверный пароль существующего пользователя.
Невалидные данные в теле запроса.
Каждый тест проверяет правильный статус-код HTTP и корректное тело ответа.
Для реализации роута и его тестов необходимо несколько вспомогательных функций.
Нам нужна функция для хэширования пароля, еще одна для сравнения пароля с хэшем, функция для генерации токена JWT для пользователя и функция для установки куки JWT. Кроме того, нам нужна функция проверки валидности токена и функция для извлечения токена JWT из куки запроса. Напишем тесты для этих функций.
Функции hashPassword
и getIsPasswordValid
будут использоваться вместе, поэтому и тестировать их имеет смысл совместно:
// src/features/user-authentication/user-authentication-helpers.test.ts
import { createId } from '@paralleldrive/cuid2';
import { describe, expect, test } from 'vitest';
import {
getIsPasswordValid,
hashPassword,
} from './user-authentication-helpers.js';
describe('getIsPasswordValid() & hashPassword()', () => {
test('дано: пароль, ожидается: хэшированный пароль', async () => {
const password = createId();
const hashedPassword = await hashPassword(password);
const actual = await getIsPasswordValid(password, hashedPassword);
const expected = true;
expect(actual).toEqual(expected);
});
});
Сначала мы используем hashPassword()
для хэширования пароля, затем - getIsPasswordValid()
для его валидации.
Пароли можно хэшировать с помощью библиотеки bcrypt
. Устанавливаем ее:
npm i bcrypt && npm i -D @types/bcrypt
Реализуем обе функции:
// src/features/user-authentication/user-authentication-helpers.ts
import bcrypt from 'bcrypt';
/**
* Хэширует пароль.
*
* @param password Пароль для хэширования.
* @returns Хэшированный пароль.
*/
export async function hashPassword(password: string) {
return await bcrypt.hash(password, 10);
}
/**
* Сравнивает пароль с хэшированным паролем.
*
* @param password Пароль для сравнения.
* @param hashedPassword Хэшированный пароль для сравнения.
* @returns true, если пароль валиден, иначе false.
*/
export async function getIsPasswordValid(
password: string,
hashedPassword: string,
) {
return await bcrypt.compare(password, hashedPassword);
}
Теперь тесты должны проходить.
Добавляем тест для функции генерации токена JWT:
// src/features/user-authentication/user-authentication-helpers.test.js
// Другие импорты...
import {
generateJwtToken,
getIsPasswordValid,
hashPassword,
} from './user-authentication-helpers.js';
// Другие тесты...
describe('generateJwtToken()', () => {
test('дано: профиль пользователя, ожидается: токен JWT', () => {
const userProfile = {
id: 'ozlnvq593weqj51j5p69adul',
email: 'Jamarcus.Haag44@hotmail.com',
name: 'Dr. Philip Lindgren',
createdAt: new Date('2022-09-25T20:03:54.119Z'),
updatedAt: new Date('2025-01-29T11:25:38.342Z'),
hashedPassword: 'b6d93ffb-8093-4940-bd1f-c9e8020851e4',
};
const jwtToken = generateJwtToken(userProfile);
const actual = jwtToken.startsWith('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9');
const expected = true;
expect(actual).toEqual(expected);
});
});
В этом тесте мы создаем простой профиль пользователя, генерируем токен для него и проверяем, что токен начинается с определенной строки. Это строка может различаться в зависимости от среды выполнения кода и переменной JWT_SECRET
, которую мы установим при реализации следующей функции.
Нам потребуется еще несколько пакетов:
npm i dotenv jsonwebtoken && npm i -D @types/jsonwebtoken
Реализуем функцию:
// src/features/user-authentication/user-authentication-helpers.ts
import bcrypt from 'bcrypt';
import dotenv from 'dotenv';
import jwt from 'jsonwebtoken';
import type { UserProfile } from '~/generated/prisma/index.js';
dotenv.config();
// Другие функции...
/**
* Генерирует токен JWT. Не забудьте определить process.env.JWT_SECRET.
*
* @param userProfile Профиль пользователя, для которого генерируется токен.
* @returns Сгенерированный токен JWT.
*/
export function generateJwtToken(userProfile: UserProfile) {
const tokenPayload = {
id: userProfile.id,
email: userProfile.email,
};
return jwt.sign(tokenPayload, process.env.JWT_SECRET as string, {
expiresIn: 60 * 60 * 24 * 365, // 1 год
});
}
dotenv.config()
загружает переменные среды из файла .env
в process.env
.
Добавляем в .env
переменную JWT_SECRET
:
JWT_SECRET=your-jwt-secret
Функция generateJwtToken
принимает профиль пользователя, извлекает из него id
и email
и создает токен JWT на их основе. Она подписывает токен секретом из среды окружения и устанавливает срок жизни токена в 1 год.
Теперь тест должен проходить.
Последняя функция, которую необходимо создать перед реализацией роута, - функция для установки куки JWT. Для этой функции не нужны тесты, поскольку для ее юнит-тестирования придется мокать (mock) объект Response
Express, и тестироваться будет в основном этот мок, а не функция. Такие функции обычно тестируются с помощью интеграционных тестов.
// src/features/user-authentication/user-authentication-helpers.ts
// Другие импорты...
import type { Response } from 'express';
import jwt from 'jsonwebtoken';
// Другие функции...
export const JWT_COOKIE_NAME = 'jwt';
/**
* Устанавливает куки JWT.
*
* @param response Объект ответа для установки куки.
* @param token Токен JWT для установки.
*/
export function setJwtCookie(response: Response, token: string) {
response.cookie(JWT_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // используем безопасные куки в продакшне
sameSite: 'strict',
});
}
Настройки куки:
httpOnly
- куки недоступна с помощью JS, что защищает от межсайтового скриптинга (cross-site scripting, XSS), предотвращая чтение токена "злыми" скриптами
secure
- куки отправляется только по HTTPS, когда NODE_ENV
установлена в production
sameSite
- куки ограничивается установившим ее сайтом, что предотвращает ее отправку с запросами, инициированными сторонними сервисами
Теперь мы можем реализовать роут /api/v1/login
:
// src/features/user-authentication/user-authentication-controller.ts
import type { Request, Response } from 'express';
import { z } from 'zod';
import { validateBody } from '~/middleware/validate.js';
import {
retrieveUserProfileFromDatabaseByEmail,
} from '../user-profile/user-profile-model.js';
import {
generateJwtToken,
getIsPasswordValid,
setJwtCookie,
} from './user-authentication-helpers.js';
export async function login(request: Request, response: Response) {
// Валидируем тело запроса на наличие валидного email и пароля
// из 8 символов, минимум
const body = await validateBody(
z.object({
email: z.email(),
password: z.string().min(8),
}),
request,
response,
);
// Пытаемся найти пользователя в БД по email
const user = await retrieveUserProfileFromDatabaseByEmail(body.email);
if (user) {
const isPasswordValid = await getIsPasswordValid(
body.password,
user.hashedPassword,
);
if (isPasswordValid) {
// Генерируем токен JWT, устанавливаем HTTP-only куки и возвращаем
// статус 200 и сообщение об успехе
const token = generateJwtToken(user);
setJwtCookie(response, token);
response.status(200).json({ message: 'Logged in successfully' });
} else {
// Если пароль невалиден, возвращаем статус 401 и сообщение об ошибке
response.status(401).json({ message: 'Invalid credentials' });
}
} else {
// Если пользователь не найден, возвращаем ошибку Unauthorized
response.status(401).json({ message: 'Invalid credentials' });
}
}
Мы определяем асинхронную функцию login
для обработки аутентификации пользователей. Функция начинается с валидации тела входящего запроса с помощью посредника validateBody
со схемой Zod. Эта схема проверяет, что тело содержит валидный email и пароль, состоящий как минимум из 8 символов.
После валидации функция пытается извлечь пользователя из БД по email путем вызова retrieveUserProfileFromDatabaseByEmail()
. Если пользователь найден, функция проверяет пароль путем его сравнения с сохраненным хэшем с помощью функции getIsPasswordValid
.
Если пароль верный, функция генерирует токен JWT с помощью generateJwtToken()
, устанавливает этот токен как HTTP-only куки в ответ с помощью setJwtCookie()
и, наконец, отправляет ответ со статусом 200 и сообщением об успехе. Если пользователь не найден или указан неверный пароль, функция возвращает статус 401 с сообщением "Invalid credentials".
Тесты все еще падают, поскольку роут не добавлен в роутер.
// src/features/user-authentication/user-authentication-routes.ts
import { Router } from 'express';
import { asyncHandler } from '~/utils/async-handler.js';
import { login } from './user-authentication-controller.js';
const router = Router();
router.post('/login', asyncHandler(login));
export { router as userAuthenticationRoutes };
Роутер также должен быть добавлен в apiV1Router
:
// src/routes.ts
import { Router } from 'express';
import { healthCheckRoutes } from '~/features/health-check/health-check-routes.js';
import { userAuthenticationRoutes } from '~/features/user-authentication/user-authentication-routes.js';
export const apiV1Router = Router();
apiV1Router.use('/health-check', healthCheckRoutes);
apiV1Router.use(userAuthenticationRoutes);
Обратите внимание на отсутствие сегмента /authentication
. Это объясняется тем, что мы хотим, чтобы эти роуты были доступными на верхнем уровне API через /login
, /register
и /logout
.
Теперь тесты должны проходить.
Обычно, начинают с реализации роута регистрации, но роут логина проще, поэтому мы начали с него.
Добавляем тесты для роута регистрации:
// src/features/user-authentication/user-authentication.test.ts
describe('/api/v1/register', () => {
test('дано: валидные данные для регистрации, ожидается: создание пользователя и возврат статуса 201', async () => {
const app = buildApp();
const email = 'test@example.com';
const password = 'password123';
const { body: actual } = await request(app)
.post('/api/v1/register')
.send({ email, password })
.expect(201);
expect(actual).toEqual({ message: 'User registered successfully' });
// Проверяем запись пользователя в БД
const createdUser = await retrieveUserProfileFromDatabaseByEmail(email);
expect(createdUser).toBeDefined();
expect(createdUser?.email).toEqual(email);
// Очистка
if (createdUser) {
await deleteUserProfileFromDatabaseById(createdUser.id);
}
});
test('дано: email, который уже существует, ожидается: возврат статуса 409', async () => {
const password = createId();
const { app, userProfile } = await setup({ password });
const { body: actual } = await request(app)
.post('/api/v1/register')
.send({ email: userProfile.email, password: 'newpassword123' })
.expect(409);
expect(actual).toEqual({ message: 'User already exists' });
});
test('дано: невалидные данные для регистрации, ожидается: возврат статуса 400', async () => {
const app = buildApp();
const { body: actual } = await request(app)
.post('/api/v1/register')
.send({})
.expect(400);
expect(actual).toEqual({
message: 'Bad Request',
errors: [
{
code: 'invalid_type',
expected: 'string',
message: 'Invalid input: expected string, received undefined',
path: ['email'],
},
{
code: 'invalid_type',
expected: 'string',
message: 'Invalid input: expected string, received undefined',
path: ['password'],
},
],
});
});
});
Сначала тест проверяет передачу валидных данных для регистрации - нового email и пароля. Затем создается новый пользователь и возвращается статус 201 с сообщением об успехе.
Второй тест проверяет, что попытка регистрации аккаунта с email, который уже использовался, возвращает статус 409 с сообщением об ошибке, что предотвращает дублирование аккаунтов.
Третий тест проверяет, что конечная точка возвращает статус 400 и правильно обрабатывает невалидные данные для регистрации.
У нас есть все необходимое для реализации роута регистрации:
// src/features/user-authentication/user-authentication-controller.ts
// Другие импорты...
import {
retrieveUserProfileFromDatabaseByEmail,
saveUserProfileToDatabase,
} from '../user-profile/user-profile-model.js';
import {
generateJwtToken,
getIsPasswordValid,
hashPassword,
setJwtCookie,
} from './user-authentication-helpers.js';
// Другие обработчики...
export async function register(request: Request, response: Response) {
// Валидируем тело запроса на наличие валидного email и пароля
// из 8 символов, минимум
const body = await validateBody(
z.object({
email: z.email(),
password: z.string().min(8),
}),
request,
response,
);
// Проверяем наличие пользователя с этим email
const existingUser = await retrieveUserProfileFromDatabaseByEmail(body.email);
if (existingUser) {
response.status(409).json({ message: 'User already exists' });
} else {
// Хэшируем пароль и создаем профиль пользователя
const hashedPassword = await hashPassword(body.password);
const user = await saveUserProfileToDatabase({
email: body.email,
hashedPassword,
});
const token = generateJwtToken(user);
setJwtCookie(response, token);
response.status(201).json({ message: 'User registered successfully' });
}
}
Мы валидируем email и пароль пользователя, как обычно, и проверяем, что пользователь еще не регистрировался.
Далее, мы хэшируем пароль и создаем профиль пользователя. Затем генерируем токен JWT, устанавливаем его как HTTP-only куки и отправляем статус 201 с сообщением об успехе.
Обратите внимание, что мы не выбрасываем ошибку 400 явно. Это связано с тем, что посредник validateBody()
сам выбрасывает такую ошибку, если тело запроса является невалидным.
// src/features/user-authentication/user-authentication-routes.ts
import { Router } from 'express';
import { asyncHandler } from '~/utils/async-handler.js';
import { login, register } from './user-authentication-controller.js';
const router = Router();
router.post('/login', asyncHandler(login));
router.post('/register', asyncHandler(register));
export { router as userAuthenticationRoutes };
Теперь тесты должны проходить.
Для проверки выхода из системы требуется только один тест, поскольку все, что нужно сделать роуту, - указать браузеру удалить куки JWT:
// src/features/user-authentication/user-authentication.test.ts
describe('/api/v1/logout', () => {
test('дано: любой запрос POST, ожидается: очистка JWT куки и возврат статуса 200', async () => {
const { app } = await setup();
const response = await request(app).post('/api/v1/logout').expect(200);
expect(response.body).toEqual({ message: 'Logged out successfully' });
// Проверяем очистку куки
const cookies = response.headers['set-cookie'] as unknown as string[];
expect(cookies).toBeDefined();
expect(cookies).toEqual([
'jwt=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Strict',
]);
});
});
Для реализации роута выхода из системы требуется еще одна вспомогательная функция:
// src/features/user-authentication/user-authentication-helpers.ts
/**
* Модифицирует ответ, указываю браузеру удалить куки JWT.
*
* @param response Объект ответа для очистки куки.
*/
export function clearJwtCookie(response: Response) {
response.clearCookie(JWT_COOKIE_NAME, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
});
}
Теперь мы можем реализовать сам роут:
// src/features/user-authentication/user-authentication-controller.ts
// Другие импорты...
import {
clearJwtCookie,
generateJwtToken,
getIsPasswordValid,
hashPassword,
setJwtCookie,
} from './user-authentication-helpers.js';
// Другие обработчики...
export async function logout(request: Request, response: Response) {
clearJwtCookie(response);
response.status(200).json({ message: 'Logged out successfully' });
}
Добавляем его в роутер:
// src/features/user-authentication/user-authentication-routes.ts
import { Router } from 'express';
import { asyncHandler } from '~/utils/async-handler.js';
import { login, logout, register } from './user-authentication-controller.js';
const router = Router();
router.post('/login', asyncHandler(login));
router.post('/register', asyncHandler(register));
router.post('/logout', asyncHandler(logout));
export { router as userAuthenticationRoutes };
Теперь тесты должны проходить.
Еще одна необходимая функция, связанная с аутентификацией, - это посредник для защиты роутов от доступа неаутентифицированных пользователей.
Нам не нужны тесты для этой функции, поскольку этот посредник:
может тестироваться только с помощью моков
будет тестироваться в интеграционных тестах
Если хотите, можете самостоятельно написать тест для функции isTokenValid
.
// src/features/user-authentication/user-authentication-helpers.ts
// Другие импорты...
import type { Request, Response } from 'express';
/**
* Проверяет валидность токена.
*
* @param token Токен для проверки.
* @returns true, если токен валиден, иначе false.
*/
const isTokenValid = (token: jwt.JwtPayload | string) => {
if (
typeof token === 'object' &&
token !== null &&
'id' in token &&
'email' in token
) {
return true;
}
return false;
};
/**
* Извлекает токен JWT из куки.
*
* @param request Объект запроса, содержащий куки.
* @returns Токен JWT из куки.
*/
export function getJwtTokenFromCookie(request: Request) {
const token = request.cookies[JWT_COOKIE_NAME];
if (!token) {
throw new Error('No token found');
}
const decodedToken = jwt.verify(token, process.env.JWT_SECRET as string);
if (isTokenValid(decodedToken)) {
return decodedToken;
}
throw new Error('Invalid token payload');
}
Функция isTokenValid
проверяет, что декодированный токен имеет правильную структуру (объект с полями id
и email
).
Функция getJwtTokenFromCookie
извлекает токен JWT из куки запроса по предопределенному названию куки (JWT_COOKIE_NAME
). Далее, токен проверяется с помощью секрета, валидируется с помощью isTokenValid()
и, если все хорошо, возвращается декодированный токен. Если токен невалиден или отсутствует, выбрасывается ошибка.
Теперь можно реализовать посредника:
// src/middleware/require-authentication.ts
import type { Request, Response } from 'express';
import { getJwtTokenFromCookie } from '~/features/user-authentication/user-authentication-helpers.js';
/**
* Извлекает полезную нагрузку из токена JWT.
* Выбрасывает ошибку, если токен отсутствует или невалиден.
*
* @param request Объект запроса для извлечения токена.
* @returns Полезная нагрузка токена, содержащая ID и email пользователя.
*/
export function requireAuthentication(request: Request, response: Response) {
try {
return getJwtTokenFromCookie(request);
} catch {
throw response.status(401).json({ message: 'Unauthorized' });
}
}
Этот посредник может использоваться следующим образом:
// temp-require-authentication-example.ts
import express from 'express';
import cookieParser from 'cookie-parser';
import { requireAuthentication } from '../middleware/require-authentication.js';
const app = express();
app.use(cookieParser()); // подключаем cookie-parser
app.get('/protected/profile', async (request, response, next) => {
try {
// Получаем полезную нагрузку токена
const { id, email } = requireAuthentication(request, response);
// Используем ID и email аутентифицированного пользователя в ответе
response.status(200).json({
message: `Hello, ${email}! Your user ID is ${id}`,
userId: id,
});
} catch (error) {
next(error);
}
});
Реализуем целую фичу.
Остался один важный вопрос: как получить аутентифицированного пользователя в тестах? Скоро вы это узнаете.
// src/features/user-profile/user-profile.test.ts
import type { UserProfile } from '@prisma/client';
import request from 'supertest';
import { describe, expect, onTestFinished, test } from 'vitest';
import { buildApp } from '~/app.js';
import {
generateJwtToken,
JWT_COOKIE_NAME,
} from '../user-authentication/user-authentication-helpers.js';
import { createPopulatedUserProfile } from './user-profile-factories.js';
import {
deleteUserProfileFromDatabaseById,
saveUserProfileToDatabase,
} from './user-profile-model.js';
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
async function setup(numberOfProfiles = 1) {
const app = buildApp();
const profiles: UserProfile[] = [];
for (let i = 0; i < numberOfProfiles; i += 1) {
const profile = await saveUserProfileToDatabase(
createPopulatedUserProfile(),
);
profiles.push(profile);
// Искусственная задержка для обеспечения уникальных временных меток createdAt
await sleep(100);
}
const token = generateJwtToken(profiles[0]!);
onTestFinished(async () => {
try {
await Promise.all(
profiles.map(profile => deleteUserProfileFromDatabaseById(profile.id)),
);
} catch {
// Нам нужен перехват ошибок для обработки тестов, удаляющих профили пользователей.
// Если тест падает и код реализации не удаляет профили пользователей,
// нам необходимо удалить их в блоке try.
// Если тест проходит и код реализации удаляет профили пользователей,
// эта очистка выбрасывает исключение
}
});
return {
app,
token,
profiles: profiles.toSorted(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
),
};
}
describe('/api/v1/user-profiles', () => {
describe('/', () => {
describe('GET', () => {
test('дано: неаутентифицированный запрос, ожидается: возврат статуса 401', async () => {
const { app } = await setup();
const { status: actual } = await request(app).get(
'/api/v1/user-profiles',
);
const expected = 401;
expect(actual).toEqual(expected);
});
test('дано: существует несколько профилей, ожидается: возврат статуса 200 с пагинированными профилями', async () => {
const { app, profiles, token } = await setup(3);
const [first, second] = profiles as [UserProfile, UserProfile];
const actual = await request(app)
.get('/api/v1/user-profiles')
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.query({ page: 1, pageSize: 2 })
.expect(200);
const expected = [
{
id: first.id,
email: first.email,
name: first.name,
hashedPassword: first.hashedPassword,
createdAt: first.createdAt.toISOString(),
updatedAt: first.updatedAt.toISOString(),
},
{
id: second.id,
email: second.email,
name: second.name,
hashedPassword: second.hashedPassword,
createdAt: second.createdAt.toISOString(),
updatedAt: second.updatedAt.toISOString(),
},
];
expect(actual.body).toHaveLength(2);
expect(actual.body).toEqual(expected);
});
test('дано: переданы параметры поисковой строки, ожидается: возврат статуса 200 с профилями запрашиваемой страницы', async () => {
const { app, profiles, token } = await setup(5);
const [third, fourth] = profiles.slice(2, 4) as [
UserProfile,
UserProfile,
];
const actual = await request(app)
.get('/api/v1/user-profiles')
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.query({ page: 2, pageSize: 2 })
.expect(200);
const expected = [
{
id: third.id,
email: third.email,
name: third.name,
createdAt: third.createdAt.toISOString(),
updatedAt: third.updatedAt.toISOString(),
hashedPassword: third.hashedPassword,
},
{
id: fourth.id,
email: fourth.email,
name: fourth.name,
createdAt: fourth.createdAt.toISOString(),
updatedAt: fourth.updatedAt.toISOString(),
hashedPassword: fourth.hashedPassword,
},
];
expect(actual.body).toHaveLength(2);
expect(actual.body).toEqual(expected);
});
test('дано: параметры поисковой строки отсутствуют, ожидается: возврат статуса 200 с дефолтными значениями пагинации', async () => {
const { app, profiles, token } = await setup(15);
const firstTenProfiles = profiles.slice(0, 10);
const actual = await request(app)
.get('/api/v1/user-profiles')
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.expect(200);
const expected = firstTenProfiles.map(profile => ({
id: profile.id,
email: profile.email,
name: profile.name,
createdAt: profile.createdAt.toISOString(),
updatedAt: profile.updatedAt.toISOString(),
hashedPassword: profile.hashedPassword,
}));
expect(actual.body).toHaveLength(10);
expect(actual.body).toEqual(expected);
});
});
});
});
Мы снова определяем вспомогательную функцию setup
. В ней создается приложение, несколько профилей в БД, настраивается очистка для удаления профилей после завершения тестов и генерируется токен JWT для аутентифицированного пользователя.
Далее определяется тест для конечной точки /api/v1/user-profiles
:
тестирование неаутентифицированного запроса: в этом случае GET-запрос должен возвращать статус 401
тестирование пагинации с несколькими профилями: проверяется, что при наличии нескольких профилей и валидной аутентификации, GET-запрос с определенными параметрами поисковой строки (page
и pageSize
) возвращает корректно пагинированные профили пользователей
тестирование пагинации с параметрами поисковой строки: проверяется, что запрос определенной страницы с помощью параметров поисковой строки возвращает правильный набор профилей пользователей
тестирование дефолтной пагинации: проверяется, что при отсутствии параметров поисковой строки, API возвращает дефолтное количество профилей (10 в нашем случае)
В тестах аутентификация выполняется путем добавления токена JWT в запросы.
// src/features/user-profile/user-profile-controller.ts
import type { Request, Response } from 'express';
import { z } from 'zod';
import { requireAuthentication } from '~/middleware/require-authentication.js';
import { validateQuery } from '~/middleware/validate.js';
import { retrieveManyUserProfilesFromDatabase } from './user-profile-model.js';
export async function getAllUserProfiles(request: Request, response: Response) {
requireAuthentication(request, response);
const query = await validateQuery(
z.object({
page: z.coerce.number().positive().default(1),
pageSize: z.coerce.number().positive().default(10),
}),
request,
response,
);
const profiles = await retrieveManyUserProfilesFromDatabase({
page: query.page,
pageSize: query.pageSize,
});
response.status(200).json(profiles);
}
Благодаря посреднику и фасадам реализация роута является тривиальной. Сначала проверяется, что пользователь аутентифицирован, затем валидируются параметры поисковой строки и, наконец, профили извлекаются из БД.
Добавляем обработчик:
// src/routes.ts
import { Router } from 'express';
import { healthCheckRoutes } from '~/features/health-check/health-check-routes.js';
import { userAuthenticationRoutes } from '~/features/user-authentication/user-authentication-routes.js';
import { userProfileRoutes } from '~/features/user-profile/user-profile-routes.js';
export const apiV1Router = Router();
apiV1Router.use('/health-check', healthCheckRoutes);
apiV1Router.use(userAuthenticationRoutes);
apiV1Router.use('/user-profiles', userProfileRoutes);
Теперь тесты должны проходить.
Напишем тесты для получения профиля пользователя, его обновления и удаления по id:
// src/features/user-profile/user-profile.test.ts
import { createId } from '@paralleldrive/cuid2';
import type { UserProfile } from '@prisma/client';
import request from 'supertest';
import { describe, expect, onTestFinished, test } from 'vitest';
import { buildApp } from '~/app.js';
import {
generateJwtToken,
JWT_COOKIE_NAME,
} from '../user-authentication/user-authentication-helpers.js';
import { createPopulatedUserProfile } from './user-profile-factories.js';
import {
deleteUserProfileFromDatabaseById,
saveUserProfileToDatabase,
} from './user-profile-model.js';
// Функция setup...
describe('/api/v1/user-profiles', () => {
describe('/', () => {
// Тесты получения списка профилей...
});
describe('/:id', () => {
describe('GET', () => {
test('дано: неаутентифицированный запрос, ожидается: возврат статуса 401', async () => {
const { app, profiles } = await setup();
const [profile] = profiles as [UserProfile];
const { status: actual } = await request(app).get(
`/api/v1/user-profiles/${profile.id}`,
);
const expected = 401;
expect(actual).toEqual(expected);
});
test('дано: профиль существует, ожидается: возврат статуса 200 с профилем', async () => {
const { app, profiles, token } = await setup();
const [profile] = profiles as [UserProfile];
const actual = await request(app)
.get(`/api/v1/user-profiles/${profile.id}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.expect(200);
const expected = {
id: profile.id,
email: profile.email,
name: profile.name,
createdAt: profile.createdAt.toISOString(),
updatedAt: profile.updatedAt.toISOString(),
hashedPassword: profile.hashedPassword,
};
expect(actual.body).toEqual(expected);
});
test('дано: профиля не существует, ожидается: возврат статуса 404 с сообщением об ошибке', async () => {
const { app, token } = await setup();
const nonExistentId = createId();
const actual = await request(app)
.get(`/api/v1/user-profiles/${nonExistentId}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.expect(404);
const expected = { message: 'Not Found' };
expect(actual.body).toEqual(expected);
});
});
describe('PATCH', () => {
test('дано: неаутентифицированный запрос, ожидается: возврат статуса 401', async () => {
const { app, profiles } = await setup();
const [profile] = profiles as [UserProfile];
const updates = { name: 'Updated Name' };
const { status: actual } = await request(app)
.patch(`/api/v1/user-profiles/${profile.id}`)
.send(updates);
const expected = 401;
expect(actual).toEqual(expected);
});
test('дано: профиль существует и новые данные валидны, ожидается: возврат статуса 200 с обновленным профилем', async () => {
const { app, profiles, token } = await setup();
const [profile] = profiles as [UserProfile];
const updates = { name: 'Updated Name', ignoredField: 'ignoreMe' };
const actual = await request(app)
.patch(`/api/v1/user-profiles/${profile.id}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.send(updates)
.expect(200);
const expected = {
id: profile.id,
email: profile.email,
name: updates.name,
createdAt: profile.createdAt.toISOString(),
updatedAt: actual.body.updatedAt,
hashedPassword: profile.hashedPassword,
};
expect(actual.body).toEqual(expected);
});
test('дано: невалидный id, ожидается: возврат статуса 404 с сообщением об ошибке', async () => {
const { app, token } = await setup();
const updates = { name: 'Updated Name' };
const nonExistentId = createId();
const actual = await request(app)
.patch(`/api/v1/user-profiles/${nonExistentId}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.send(updates)
.expect(404);
const expected = { message: 'Not Found' };
expect(actual.body).toEqual(expected);
});
test('дано: пустой объект обновления, ожидается: возврат статуса 400 с сообщением об ошибке', async () => {
const { app, profiles, token } = await setup();
const [profile] = profiles as [UserProfile];
const actual = await request(app)
.patch(`/api/v1/user-profiles/${profile.id}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.send({})
.expect(400);
const expected = { message: 'No valid fields to update' };
expect(actual.body).toEqual(expected);
});
test('дано: попытка обновления id, ожидается: возврат статуса 400 с сообщением об ошибке', async () => {
const { app, profiles, token } = await setup();
const [profile] = profiles as [UserProfile];
const updates = { id: 'new-id' };
const actual = await request(app)
.patch(`/api/v1/user-profiles/${profile.id}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.send(updates)
.expect(400);
const expected = {
message: 'Bad Request',
errors: [
{
code: 'invalid_type',
expected: 'never',
message: 'Invalid input: expected never, received string',
path: ['id'],
},
],
};
expect(actual.body).toEqual(expected);
});
test('дано: отсутствует id в URL, ожидается: возврат статуса 404', async () => {
const { app, token } = await setup();
const updates = { name: 'Updated Name' };
const actual = await request(app)
.patch('/api/v1/user-profiles/')
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.send(updates);
const expected = 404;
expect(actual.status).toEqual(expected);
});
});
describe('DELETE', () => {
test('дано: неаутентифицированный запрос, ожидается: возврат статуса 401', async () => {
const { app, profiles } = await setup();
const [profile] = profiles as [UserProfile];
const { status: actual } = await request(app).delete(
`/api/v1/user-profiles/${profile.id}`,
);
const expected = 401;
expect(actual).toEqual(expected);
});
test('дано: профиль существует, ожидается: возврат статуса 200 с удаленным профилем', async () => {
const { app, profiles, token } = await setup();
const [profile] = profiles as [UserProfile];
const actual = await request(app)
.delete(`/api/v1/user-profiles/${profile.id}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.expect(200);
const expected = {
id: profile.id,
email: profile.email,
name: profile.name,
createdAt: profile.createdAt.toISOString(),
updatedAt: profile.updatedAt.toISOString(),
hashedPassword: profile.hashedPassword,
};
expect(actual.body).toEqual(expected);
});
test('дано: профиля не существует, ожидается: возврат статуса 404 с сообщением об ошибке', async () => {
const { app, token } = await setup();
const nonExistentId = createId();
const actual = await request(app)
.delete(`/api/v1/user-profiles/${nonExistentId}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.expect(404);
const expected = { message: 'Not Found' };
expect(actual.body).toEqual(expected);
});
test('дано: отсутствует id в URL, ожидается: возврат статуса 404', async () => {
const { app, token } = await setup();
const actual = await request(app)
.delete('/api/v1/user-profiles/')
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`]);
const expected = 404;
expect(actual.status).toEqual(expected);
});
});
});
});
Для GET-запросов тестируется извлечение профиля по его ID. Если профиль существует, API должен возвращать статус 200 с верными данными профиля; если профиля не существует, ожидается возврат статуса 404 с соответствующим сообщением об ошибке.
Для PATCH-запросов проверяется несколько сценариев обновления. В ответ на попытку неаутентифицированного обновления возвращается статус 401, а в ответ на валидные новые данные существующего профиля - статус 200 с обновленным профилем. Кроме того, тестируются крайние случаи, такие как попытка обновления несуществующего профиля, отправка пустого объекта обновления и попытка модификации иммутабельных полей, таких как ID профиля. Во всех этих случаях должны возвращаться соответствующие ответы с ошибками.
Для DELETE-запросов проверяется, что неаутентифицированное удаление отклоняется со статусом 401. При удалении существующего профиля с валидной аутентификацией ожидается возврат статуса 200 с удаленным профилем. Также тестируется, что попытка удаления несуществующего профиля или отсутствие ID профиля в URL заканчиваются статусом 404 с сообщением об ошибке.
Существует много способов реализовать эти роуты. Мы оставим проверку дубликатов Prisma, а для определения правильной ошибки может быть использована простая вспомогательная функция getErrorMessage
. Напишем для нее тесты:
import { faker } from '@faker-js/faker';
import { describe, expect, test } from 'vitest';
import { getErrorMessage } from './get-error-message.js';
describe('getErrorMessage()', () => {
test('дано: ошибка, ожидается: возврат сообщения об ошибке', () => {
const message = faker.word.words();
expect(getErrorMessage(new Error(message))).toEqual(message);
});
test('дано: выброс строки, ожидается: возврат строки', () => {
expect.assertions(1);
const someString = faker.lorem.words();
try {
throw someString;
} catch (error) {
expect(getErrorMessage(error)).toEqual(someString);
}
});
test('дано: выброс числа, ожидается: возврат числа в виде строки', () => {
expect.assertions(1);
const someNumber = 1;
try {
throw someNumber;
} catch (error) {
expect(getErrorMessage(error)).toEqual(String(someNumber));
}
});
test('дано: ошибка, расширяющая класс ошибок, ожидается: возврат сообщения об ошибке', () => {
class CustomError extends Error {
public constructor(message: string) {
super(message);
}
}
const message = faker.word.words();
expect(getErrorMessage(new CustomError(message))).toEqual(message);
});
test('дано: кастомный объект со свойством message, ожидается: возврат свойства message объекта', () => {
const message = faker.word.words();
expect(getErrorMessage({ message })).toEqual(message);
});
test('дано: циклическая зависимость, ожидается: правильная обработка такой зависимости', () => {
expect.assertions(1);
const object = { circular: this };
try {
throw object;
} catch (error) {
expect(getErrorMessage(error)).toEqual('[object Object]');
}
});
});
Эти тесты проверяют разные типы ошибок, передаваемые getErrorMessage()
. Проверяется, что getErrorMessage()
корректно извлекает сообщение из стандартной Error
, возвращает строку, если выбрасывается строка или число. Также проверяется, что функция правильно обрабатывает кастомные ошибки и объекты со свойством message
, а также справляется с циклическими зависимостями путем возврата дефолтного строкового представления объекта.
Реализуем функцию getErrorMessage
:
// src/utils/get-error-message.ts
type ErrorWithMessage = {
message: string;
};
// Проверяет наличие свойства `message` в стандартных ошибках,
// кастомных ошибках и объектах
function isErrorWithMessage(error: unknown): error is ErrorWithMessage {
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as Record<string, unknown>).message === 'string'
);
}
function toErrorWithMessage(maybeError: unknown): ErrorWithMessage {
if (isErrorWithMessage(maybeError)) return maybeError;
try {
if (typeof maybeError === 'string') return new Error(maybeError);
return new Error(JSON.stringify(maybeError));
} catch {
// JSON.stringify() выбрасывает исключение в случае циклической зависимости.
// Мы перехватываем его здесь и приводим к строке [object Object]
return new Error(String(maybeError));
}
}
/**
* Извлекает сообщение из ошибки или другого исключения.
*
* @param error Ошибка или другое исключение.
* @returns Строка с сообщением об ошибке.
*
* @example
*
* Экземпляр Error:
*
* ```typescript
* getErrorMessage(new Error('Something went wrong'))
* // 'Something went wrong'
* ```
*
* Объект:
*
* ```typescript
* getErrorMessage({ message: 'Something went wrong' })
* // 'Something went wrong'
* ```
*
* Примитив:
*
* ```typescript
* getErrorMessage('Something went wrong')
* // 'Something went wrong'
* ```
*/
export function getErrorMessage(error: unknown) {
return toErrorWithMessage(error).message;
}
Начинаем с определения типа ErrorWithMessage
- объекта со свойством message
типа string
, затем реализует защитник типа (type guard) isErrorWithMessage
для подтверждения этого.
Далее, определяем функцию toErrorWithMessage
, которая конвертирует любое выброшенное значение в объект ErrorWithMessage
.
Наконец, getErrorMessage()
извлекает свойство message
из конвертированного объекта, обеспечивая согласованность сообщений об ошибках.
Теперь можно реализовать роуты:
// src/features/user-profile/user-profile-controller.ts
// Другие импорты...
import {
validateBody,
validateParams,
validateQuery,
} from '~/middleware/validate.js';
import { getErrorMessage } from '~/utils/get-error-message.js';
import {
deleteUserProfileFromDatabaseById,
retrieveManyUserProfilesFromDatabase,
retrieveUserProfileFromDatabaseById,
updateUserProfileInDatabaseById,
} from './user-profile-model.js';
// Обработчик получения списка профилей...
export async function getUserProfileById(request: Request, response: Response) {
requireAuthentication(request, response);
const { id } = await validateParams(
z.object({ id: z.cuid2() }),
request,
response,
);
const profile = await retrieveUserProfileFromDatabaseById(id);
if (profile) {
response.status(200).json(profile);
} else {
response.status(404).json({ message: 'Not Found' });
}
}
export async function updateUserProfile(request: Request, response: Response) {
requireAuthentication(request, response);
const { id } = await validateParams(
z.object({ id: z.cuid2() }),
request,
response,
);
const body = await validateBody(
z.object({
email: z.email().optional(),
name: z.string().optional(),
id: z.never().optional(),
}),
request,
response,
);
// Определяем наличие полей для обновления
if (Object.keys(body).length === 0) {
response.status(400).json({ message: 'No valid fields to update' });
return;
}
// Определяем попытку обновления id
if ('id' in body) {
response.status(400).json({ message: 'ID cannot be updated' });
return;
}
try {
const updatedProfile = await updateUserProfileInDatabaseById({
id,
data: body,
});
response.status(200).json(updatedProfile);
} catch (error) {
const message = getErrorMessage(error);
if (message.includes('No record was found for an update')) {
response.status(404).json({ message: 'Not Found' });
} else if (message.includes('Unique constraint failed')) {
response.status(409).json({ message: 'Profile already exists' });
} else {
throw error;
}
}
}
export async function deleteUserProfile(request: Request, response: Response) {
requireAuthentication(request, response);
const { id } = await validateParams(
z.object({ id: z.cuid2() }),
request,
response,
);
try {
const deletedProfile = await deleteUserProfileFromDatabaseById(id);
response.status(200).json(deletedProfile);
} catch (error) {
const message = getErrorMessage(error);
if (message.includes('No record was found for a delete')) {
response.status(404).json({ message: 'Not Found' });
} else {
throw error;
}
}
}
Добавляем эти роуты в роутер:
// src/features/user-profile/user-profile-routes.ts
import { Router } from 'express';
import { asyncHandler } from '~/utils/async-handler.js';
import {
deleteUserProfile,
getAllUserProfiles,
getUserProfileById,
updateUserProfile,
} from './user-profile-controller.js';
const router = Router();
router.get('/', asyncHandler(getAllUserProfiles));
router.get('/:id', asyncHandler(getUserProfileById));
router.patch('/:id', asyncHandler(updateUserProfile));
router.delete('/:id', asyncHandler(deleteUserProfile));
export { router as userProfileRoutes };
Теперь тесты должны проходить.
Для создания профиля пользователя не нужен отдельный роут, поскольку за это отвечает роут регистрации. Разумеется, в некоторых приложениях у пользователей будет возможность создавать аккаунты для других пользователей. В этом случае потребуется отдельный роут.
В этом туториале мы охватили 20% навыков по работе с Express с поддержкой TypeScript, что охватывает 80% (прим. пер.: я бы сказал процентов 40:)) функционала реальных приложений. Теперь идите и создайте что-нибудь классное!
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩