bcfc275bb3
Раздел «Друзья» по запросу пользователя: - первая иконка рейки — «Друзья», серверы ниже за разделителем; личные беседы не засоряют список серверов; - сайдбар друзей: беседы с точками статуса и бейджами непрочитанного, список друзей с кликом на беседу; - контент: пустое состояние «Добавь друзей для общения» с поиском, поиск по логину (debounce), добавление в друзья, входящие и исходящие заявки, меню «…» (удалить/заблокировать), поповер последнего входа в часовом поясе пользователя (по умолчанию МСК), блок «Присоединиться по приглашению» с предпросмотром сервера; - личный чат на существующих компонентах: права по can_send канала, заголовок с именем и статусом собеседника, поиск, пины, ack и typing работают; - realtime: READY dm_channels, события DM_CHANNEL_CREATE, RELATIONSHIP_UPDATE, PRESENCE_UPDATE и MESSAGE_CREATE в беседах; - новый пользователь: «У вас пока нет серверов», создание сервера и вход по приглашению (автовступления нет); - 239 тестов Vitest (было 219), тест паритета ключей i18n (527 ключей).
185 lines
7.5 KiB
TypeScript
185 lines
7.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
|
|
import {
|
|
apiError,
|
|
findRequest,
|
|
installFetch,
|
|
json,
|
|
makeUser,
|
|
recordedRequests,
|
|
renderApp,
|
|
} from './helpers';
|
|
|
|
const instanceOpen = {
|
|
name: 'glchat-test',
|
|
version: 'v0.1.0-test',
|
|
registration_enabled: true,
|
|
allow_guild_creation: true,
|
|
max_guilds_per_user: 5,
|
|
max_members_per_guild: 100,
|
|
max_message_length: 2000,
|
|
main_guild_id: 'g-main',
|
|
user_count: 1,
|
|
guild_count: 1,
|
|
};
|
|
|
|
const totpLabel = 'Код 2FA или резервный код';
|
|
|
|
/** Ответы для входа: логин и оболочка приложения. */
|
|
function loginRoutes(extra: Parameters<typeof installFetch>[0] = []) {
|
|
const user = makeUser();
|
|
return installFetch([
|
|
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
|
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user }) },
|
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
|
...extra,
|
|
]);
|
|
}
|
|
|
|
function loginBodies(fetchMock: ReturnType<typeof installFetch>) {
|
|
return recordedRequests(fetchMock).filter(
|
|
(request) => request.url.includes('/auth/login') && request.method === 'POST',
|
|
);
|
|
}
|
|
|
|
describe('страница входа', () => {
|
|
it('поле кода 2FA показано сразу и принимает резервные коды', async () => {
|
|
installFetch([{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }]);
|
|
|
|
renderApp('/login');
|
|
|
|
const field = await screen.findByLabelText(totpLabel);
|
|
expect(field).toBeVisible();
|
|
expect(field).toHaveAttribute('autocomplete', 'one-time-code');
|
|
expect(field).toHaveAttribute('inputmode', 'text');
|
|
// Резервный код вида `a8eh-pshp-t8st` должен влезать целиком.
|
|
expect(Number(field.getAttribute('maxlength'))).toBeGreaterThanOrEqual(20);
|
|
expect(field).not.toHaveAttribute('pattern');
|
|
expect(screen.getByText(/резервный код вида xxxx-xxxx-xxxx/)).toBeVisible();
|
|
});
|
|
|
|
it('успешный вход переводит в /app и уходит без totp_code при пустом поле', async () => {
|
|
const fetchMock = loginRoutes([
|
|
{ match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) },
|
|
]);
|
|
|
|
const { router } = renderApp('/login');
|
|
const visitor = userEvent.setup();
|
|
|
|
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
|
|
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
|
|
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
|
|
|
await waitFor(() => {
|
|
expect(router.state.location.pathname).toBe('/app/empty');
|
|
});
|
|
expect(await screen.findByRole('heading', { name: 'У вас пока нет серверов' })).toBeVisible();
|
|
// Автовступления в «главный сервер» больше нет: вход ведёт на пустой экран.
|
|
expect(findRequest(fetchMock, { url: '/guilds/g-main/join', method: 'POST' })).toBeUndefined();
|
|
|
|
expect(findRequest(fetchMock, { url: '/auth/login', method: 'POST' })?.body).toEqual({
|
|
email: 'alice@example.com',
|
|
password: 'super-secret-1',
|
|
});
|
|
});
|
|
|
|
it('код 2FA уходит вместе с логином в первом же запросе', async () => {
|
|
const fetchMock = loginRoutes([
|
|
{ match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) },
|
|
]);
|
|
|
|
renderApp('/login');
|
|
const visitor = userEvent.setup();
|
|
|
|
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
|
|
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
|
|
await visitor.type(screen.getByLabelText(totpLabel), '123456');
|
|
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
|
|
|
await waitFor(() => {
|
|
expect(loginBodies(fetchMock)).toHaveLength(1);
|
|
});
|
|
expect(loginBodies(fetchMock)[0]?.body).toEqual({
|
|
email: 'alice@example.com',
|
|
password: 'super-secret-1',
|
|
totp_code: '123456',
|
|
});
|
|
});
|
|
|
|
it('резервный код из 14 символов уходит на сервер целиком', async () => {
|
|
const fetchMock = loginRoutes();
|
|
|
|
const { router } = renderApp('/login');
|
|
const visitor = userEvent.setup();
|
|
|
|
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
|
|
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
|
|
await visitor.type(screen.getByLabelText(totpLabel), 'a8eh-pshp-t8st');
|
|
|
|
// Поле не обрезает значение: 14 символов с дефисами видны целиком.
|
|
expect(screen.getByLabelText(totpLabel)).toHaveValue('a8eh-pshp-t8st');
|
|
|
|
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
|
|
|
await waitFor(() => {
|
|
expect(loginBodies(fetchMock)[0]?.body).toEqual({
|
|
email: 'alice@example.com',
|
|
password: 'super-secret-1',
|
|
totp_code: 'a8eh-pshp-t8st',
|
|
});
|
|
});
|
|
// Вход не требует предварительной попытки без кода.
|
|
expect(loginBodies(fetchMock)).toHaveLength(1);
|
|
await waitFor(() => {
|
|
expect(router.state.location.pathname).toBe('/app/empty');
|
|
});
|
|
});
|
|
|
|
it('при auth.2fa_required подсказывает про код и сохраняет логин с паролем', async () => {
|
|
const user = makeUser();
|
|
let loginAttempts = 0;
|
|
const fetchMock = installFetch([
|
|
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
|
{
|
|
match: '/api/v1/auth/login',
|
|
method: 'POST',
|
|
response: () => {
|
|
loginAttempts += 1;
|
|
return loginAttempts === 1 ? apiError('auth.2fa_required', 401) : json({ user });
|
|
},
|
|
},
|
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
|
]);
|
|
|
|
const { router } = renderApp('/login');
|
|
const visitor = userEvent.setup();
|
|
|
|
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
|
|
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
|
|
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
|
|
|
// Подсказка понятная, а введённые логин и пароль остались на месте.
|
|
expect(await screen.findByText(/Введите код 2FA или резервный код/)).toBeVisible();
|
|
expect(screen.getByLabelText('Почта')).toHaveValue('alice@example.com');
|
|
expect(screen.getByLabelText('Пароль')).toHaveValue('super-secret-1');
|
|
|
|
// Код, введённый после подсказки, не теряется при повторной отправке.
|
|
await visitor.type(screen.getByLabelText(totpLabel), 'a8eh-pshp-t8st');
|
|
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
|
|
|
await waitFor(() => {
|
|
expect(router.state.location.pathname).toBe('/app/empty');
|
|
});
|
|
expect(loginBodies(fetchMock)).toHaveLength(2);
|
|
expect(loginBodies(fetchMock)[1]?.body).toEqual({
|
|
email: 'alice@example.com',
|
|
password: 'super-secret-1',
|
|
totp_code: 'a8eh-pshp-t8st',
|
|
});
|
|
});
|
|
});
|