feat: синхронизация бэкенда с документацией (AGENT_TASK_BACKEND_SYNC)
- Добвлен @fastify/cookie и настройку httpOnly cookie для refresh token
- Добавлен префикс /api/v1 для auth, profile, tests, admin
- Скорректировано в Login: возвращать user (id, nickname, avatarUrl, role, emailVerified),
ставить refreshToken в Set-Cookie
- Скорректировано в Logout: Bearer + cookie, пустое тело, 200 + { message }, очищать cookie
- Скорректировано в Refresh: token из cookie, пустое тело, 200 + { accessToken }, Set-Cookie
- Добавлено в getPrivateProfile: поля role и plan
- Скорректировано в Tests: score = количество правильных, ответ { score, totalQuestions, percentage }
- Добавлено в question_cache_meta: поля valid, retryCount, questionsGenerated
- Обновлены тесты
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import fp from 'fastify-plugin';
|
||||
import { AppError } from '../../src/utils/errors.js';
|
||||
import authPlugin from '../../src/plugins/auth.js';
|
||||
import { authRoutes } from '../../src/routes/auth.js';
|
||||
import type { MockDb } from '../test-utils.js';
|
||||
import { createMockDb } from '../test-utils.js';
|
||||
|
||||
const mockDatabasePlugin = (db: MockDb) =>
|
||||
fp(async (app) => {
|
||||
app.decorate('db', db);
|
||||
}, { name: 'database' });
|
||||
|
||||
/** Mock Redis for login lockout in auth tests. Implements ttl, setex, del, eval. */
|
||||
const mockRedis = {
|
||||
async ttl(_key: string): Promise<number> {
|
||||
@@ -50,7 +58,6 @@ export async function buildAuthTestApp(mockDb?: MockDb): Promise<FastifyInstance
|
||||
return reply.status(500).send({ error: { code: 'INTERNAL_ERROR', message: error.message } });
|
||||
});
|
||||
|
||||
app.decorate('db', db);
|
||||
app.decorate('redis', mockRedis);
|
||||
app.decorate('rateLimitOptions', {
|
||||
register: { max: 100, timeWindow: '1 hour' },
|
||||
@@ -60,7 +67,10 @@ export async function buildAuthTestApp(mockDb?: MockDb): Promise<FastifyInstance
|
||||
apiGuest: { max: 100, timeWindow: '1 minute' },
|
||||
});
|
||||
|
||||
await app.register(authRoutes, { prefix: '/auth' });
|
||||
await app.register(mockDatabasePlugin(db));
|
||||
await app.register(cookie, { secret: 'test-secret-at-least-32-characters-long' });
|
||||
await app.register(authPlugin);
|
||||
await app.register(authRoutes, { prefix: '/api/v1/auth' });
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../src/config/env.js', () => ({
|
||||
env: {
|
||||
NODE_ENV: 'test',
|
||||
JWT_SECRET: 'test-secret',
|
||||
},
|
||||
}));
|
||||
|
||||
import { buildAuthTestApp } from '../helpers/build-test-app.js';
|
||||
import {
|
||||
createMockDb,
|
||||
@@ -17,6 +25,7 @@ vi.mock('../../src/utils/jwt.js', () => ({
|
||||
signAccessToken: vi.fn().mockResolvedValue('access-token'),
|
||||
signRefreshToken: vi.fn().mockResolvedValue('refresh-token'),
|
||||
verifyToken: vi.fn(),
|
||||
isAccessPayload: vi.fn((p: { type?: string }) => p?.type === 'access'),
|
||||
isRefreshPayload: vi.fn(),
|
||||
hashToken: vi.fn((t: string) => `hash-${t}`),
|
||||
}));
|
||||
@@ -49,7 +58,7 @@ describe('Auth routes integration', () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
url: '/api/v1/auth/register',
|
||||
payload: {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
@@ -71,7 +80,7 @@ describe('Auth routes integration', () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
url: '/api/v1/auth/register',
|
||||
payload: {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
@@ -87,7 +96,7 @@ describe('Auth routes integration', () => {
|
||||
it('returns 422 when validation fails', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
url: '/api/v1/auth/register',
|
||||
payload: {
|
||||
email: 'short',
|
||||
password: '123', // too short
|
||||
@@ -102,10 +111,14 @@ describe('Auth routes integration', () => {
|
||||
});
|
||||
|
||||
describe('POST /auth/login', () => {
|
||||
it('returns tokens when credentials are valid', async () => {
|
||||
it('returns user and tokens when credentials are valid', async () => {
|
||||
const mockUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
nickname: 'tester',
|
||||
avatarUrl: null,
|
||||
role: 'free',
|
||||
emailVerifiedAt: null,
|
||||
passwordHash: 'hashed',
|
||||
};
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
@@ -117,12 +130,16 @@ describe('Auth routes integration', () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'test@example.com', password: 'password123' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.user).toBeDefined();
|
||||
expect(body.user.id).toBe('user-1');
|
||||
expect(body.user.email).toBe('test@example.com');
|
||||
expect(body.user.nickname).toBeDefined();
|
||||
expect(body.accessToken).toBe('access-token');
|
||||
expect(body.refreshToken).toBe('refresh-token');
|
||||
});
|
||||
@@ -134,7 +151,7 @@ describe('Auth routes integration', () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'unknown@example.com', password: 'wrong' },
|
||||
});
|
||||
|
||||
@@ -143,23 +160,31 @@ describe('Auth routes integration', () => {
|
||||
});
|
||||
|
||||
describe('POST /auth/logout', () => {
|
||||
it('returns 204 on success', async () => {
|
||||
it('returns 200 with message on success (Bearer + cookie)', async () => {
|
||||
vi.mocked(verifyToken).mockResolvedValueOnce({
|
||||
sub: 'user-1',
|
||||
email: 'test@example.com',
|
||||
type: 'access',
|
||||
} as never);
|
||||
(mockDb.delete as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
deleteChain()
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/logout',
|
||||
payload: { refreshToken: 'some-token' },
|
||||
url: '/api/v1/auth/logout',
|
||||
headers: { authorization: 'Bearer valid-access-token' },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.message).toBe('Logged out successfully');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/refresh', () => {
|
||||
it('returns new tokens when refresh token is valid', async () => {
|
||||
it('returns accessToken when refresh token from cookie is valid', async () => {
|
||||
vi.mocked(verifyToken).mockResolvedValueOnce({
|
||||
sub: 'user-1',
|
||||
sid: 'sid-1',
|
||||
@@ -178,14 +203,15 @@ describe('Auth routes integration', () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/refresh',
|
||||
payload: { refreshToken: 'valid-refresh-token' },
|
||||
url: '/api/v1/auth/refresh',
|
||||
payload: {},
|
||||
headers: { cookie: 'refreshToken=valid-refresh-token' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.accessToken).toBe('access-token');
|
||||
expect(body.refreshToken).toBe('refresh-token');
|
||||
expect(body.refreshToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,7 +235,7 @@ describe('Auth routes integration', () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/verify-email',
|
||||
url: '/api/v1/auth/verify-email',
|
||||
payload: { userId: 'user-1', code: 'ABC123' },
|
||||
});
|
||||
|
||||
@@ -228,7 +254,7 @@ describe('Auth routes integration', () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/forgot-password',
|
||||
url: '/api/v1/auth/forgot-password',
|
||||
payload: { email: 'test@example.com' },
|
||||
});
|
||||
|
||||
@@ -255,7 +281,7 @@ describe('Auth routes integration', () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/reset-password',
|
||||
url: '/api/v1/auth/reset-password',
|
||||
payload: { token: 'valid-token', newPassword: 'newPassword123' },
|
||||
});
|
||||
|
||||
|
||||
@@ -91,10 +91,14 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('returns tokens when credentials are valid', async () => {
|
||||
it('returns user and tokens when credentials are valid', async () => {
|
||||
const mockUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
nickname: 'tester',
|
||||
avatarUrl: null,
|
||||
role: 'free',
|
||||
emailVerifiedAt: null,
|
||||
passwordHash: 'hashed',
|
||||
};
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
@@ -109,6 +113,9 @@ describe('AuthService', () => {
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
expect(result.user).toBeDefined();
|
||||
expect(result.user.id).toBe('user-1');
|
||||
expect(result.user.email).toBe('test@example.com');
|
||||
expect(result.accessToken).toBe('access-token');
|
||||
expect(result.refreshToken).toBe('refresh-token');
|
||||
expect(verifyPassword).toHaveBeenCalledWith('hashed', 'password123');
|
||||
|
||||
@@ -214,7 +214,7 @@ describe('TestsService', () => {
|
||||
describe('getHistory', () => {
|
||||
it('returns paginated test history', async () => {
|
||||
const mockTests = [
|
||||
{ id: 't-1', userId: 'user-1', stack: 'js', level: 'beginner', questionCount: 1, mode: 'fixed', status: 'completed', score: 100, startedAt: new Date(), finishedAt: new Date(), timeLimitSeconds: null },
|
||||
{ id: 't-1', userId: 'user-1', stack: 'js', level: 'beginner', questionCount: 1, mode: 'fixed', status: 'completed', score: 1, startedAt: new Date(), finishedAt: new Date(), timeLimitSeconds: null },
|
||||
];
|
||||
const mockTqRows = [];
|
||||
(mockDb.select as ReturnType<typeof vi.fn>)
|
||||
|
||||
Reference in New Issue
Block a user