Files
glchat/web/tests/guildEmojisSettings.test.tsx
grendervill 6afbdf621b fix(web): раздел «Эмодзи» обновляется событием, а не перезапросом
`GUILD_EMOJIS_UPDATE` несёт набор эмодзи целиком, но раздел настроек читает
REST-кэш, а клиент на событие запрос инвалидировал. Под нагрузкой повторный
`GET /guilds/{id}/emojis` попадает под лимит частоты (429), а запрос списка идёт
с `retry: 0` — список оставался старым до перезахода. Тот же дефект, что у
раздела «Звуки» (коммит 4175b14), и та же правка: событие кладёт набор в кэш
запроса через `setQueryData`, а ошибка перезапроса больше не прячет уже
полученный список.

Регрессионный тест `web/tests/guildEmojisSettings.test.tsx` («показывает эмодзи
из GUILD_EMOJIS_UPDATE, даже если список по REST не отдался») падает на старом
коде.
2026-09-26 16:16:16 +03:00

385 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it } from 'vitest';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
apiError,
findRequest,
installFetch,
installGatewaySocket,
json,
makeReadySnapshot,
makeUser,
recordedRequests,
renderApp,
requestUrl,
resetStores,
type FetchRoute,
} from './helpers';
/**
* Секция «Эмодзи» в настройках сервера (AGENT.md 7.12): список загруженных
* эмодзи со счётчиком, загрузка с клиентскими проверками, переименование и
* удаление. Лента и пикер видят изменения сразу: событие `GUILD_EMOJIS_UPDATE`
* несёт набор целиком и попадает прямо в кэш запроса, а свои операции
* инвалидируют список.
*/
const user = makeUser();
const pepe = { id: 'e-1', name: 'pepe', file_id: '900', animated: false, url: '/files/900' };
const parrot = {
id: 'e-2',
name: 'party_parrot',
file_id: '901',
animated: true,
url: '/files/901',
size_bytes: 2048,
};
function guildDetail(permissions: string[]) {
return {
id: 'g-1',
name: 'Сервер',
description: '',
owner_id: 'user-9',
is_main: true,
member_count: 1,
roles: [],
my_role_ids: [],
my_permissions: permissions,
};
}
interface RoutesOptions {
permissions?: string[];
emojis?: unknown[];
extra?: FetchRoute[];
uploadResponse?: () => Response;
}
/** Маршруты оболочки, карточки сервера и ручек эмодзи. */
function routes(options: RoutesOptions = {}) {
const permissions = options.permissions ?? ['MANAGE_EMOJIS'];
return [
...(options.extra ?? []),
{
match: '/api/v1/guilds/g-1/emojis',
method: 'GET',
response: () => json({ emojis: options.emojis ?? [pepe, parrot] }),
},
{
match: '/api/v1/guilds/g-1/emojis',
method: 'POST',
response:
options.uploadResponse ?? (() => json({ emoji: { ...pepe, id: 'e-3', name: 'kek' } })),
},
{
match: '/api/v1/users/@me/guilds',
response: () => json({ guilds: [guildDetail(permissions)] }),
},
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/guilds/g-1', response: () => json({ guild: guildDetail(permissions) }) },
] satisfies FetchRoute[];
}
/** Открывает настройки сервера и дожидается секции «Эмодзи». */
async function openEmojisSection() {
renderApp('/settings/servers/g-1');
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
const counter = await within(dialog).findByTestId('guild-emojis-counter');
return { dialog, counter };
}
/** Тело multipart-запроса на загрузку эмодзи. */
function uploadForm(fetchMock: ReturnType<typeof installFetch>): FormData | undefined {
const call = fetchMock.mock.calls.find(([input, init]) => {
const method = (init?.method ?? 'GET').toUpperCase();
return method === 'POST' && requestUrl(input).includes('/guilds/g-1/emojis');
});
return call?.[1]?.body as FormData | undefined;
}
/** Один эмодзи нужного размера и формата. */
function emojiFile(name = 'kek.png', type = 'image/png', size?: number): File {
const file = new File(['картинка'], name, { type });
if (size !== undefined) {
Object.defineProperty(file, 'size', { value: size });
}
return file;
}
beforeEach(() => {
resetStores();
});
describe('настройки сервера: секция «Эмодзи»', () => {
it('показывает список, счётчик и пометку анимированного', async () => {
installFetch(routes());
const { dialog } = await openEmojisSection();
const list = await within(dialog).findByTestId('guild-emojis');
expect(within(list).getByText(':pepe:')).toBeVisible();
expect(within(list).getByText(':party_parrot:')).toBeVisible();
expect(list.querySelector('img[src="/files/900"]')).not.toBeNull();
expect(list.querySelector('img[alt=":pepe:"]')).not.toBeNull();
expect(within(list).getByText('анимированный')).toBeVisible();
// Размер показываем, когда сервер его прислал.
expect(within(list).getByText('2 KB')).toBeVisible();
expect(within(dialog).getByTestId('guild-emojis-counter')).toHaveTextContent('2 из 100');
});
it('скрыта без права MANAGE_EMOJIS', async () => {
installFetch(routes({ permissions: ['MANAGE_GUILD'] }));
renderApp('/settings/servers/g-1');
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
expect(await within(dialog).findByLabelText('Название сервера')).toBeVisible();
expect(within(dialog).queryByText('Эмодзи')).toBeNull();
expect(within(dialog).queryByTestId('guild-emojis-counter')).toBeNull();
});
it('загружает эмодзи multipart-запросом и перечитывает список', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(routes());
const { dialog } = await openEmojisSection();
await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'kek');
await visitor.upload(
within(dialog).getByLabelText('Файл эмодзи'),
emojiFile('kek.png', 'image/png'),
);
await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
await waitFor(() => {
expect(uploadForm(fetchMock)).toBeDefined();
});
const form = uploadForm(fetchMock);
expect(form?.get('name')).toBe('kek');
expect((form?.get('file') as File | null)?.name).toBe('kek.png');
// Список перечитан: инвалидация после успешной загрузки.
await waitFor(() => {
expect(
recordedRequests(fetchMock).filter(
(request) => request.url.includes('/guilds/g-1/emojis') && request.method === 'GET',
).length,
).toBeGreaterThan(1);
});
});
it('принимает файл перетаскиванием в область загрузки', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(routes());
const { dialog } = await openEmojisSection();
fireEvent.drop(within(dialog).getByTestId('emoji-dropzone'), {
dataTransfer: { files: [emojiFile('drop.png', 'image/png')] },
});
// Имя файла видно в области загрузки — файл принят.
expect(await within(dialog).findByText(/drop\.png/)).toBeVisible();
await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'drop');
await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
await waitFor(() => {
expect(uploadForm(fetchMock)?.get('name')).toBe('drop');
});
});
it('не отправляет файл неподдерживаемого формата', async () => {
const fetchMock = installFetch(routes());
const { dialog } = await openEmojisSection();
// Поле принимает только картинки, поэтому подкладываем файл напрямую —
// как это делает перетаскивание в область загрузки.
fireEvent.change(within(dialog).getByLabelText('Файл эмодзи'), {
target: { files: [emojiFile('kek.txt', 'text/plain')] },
});
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Поддерживаются PNG, JPEG, WebP и GIF.',
);
expect(uploadForm(fetchMock)).toBeUndefined();
});
it('не отправляет файл больше 512 КБ', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(routes());
const { dialog } = await openEmojisSection();
await visitor.upload(
within(dialog).getByLabelText('Файл эмодзи'),
emojiFile('big.png', 'image/png', 512 * 1024 + 1),
);
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Файл больше допустимого размера.',
);
expect(uploadForm(fetchMock)).toBeUndefined();
});
it('проверяет имя по формату сервера', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(routes());
const { dialog } = await openEmojisSection();
await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'x');
await visitor.upload(
within(dialog).getByLabelText('Файл эмодзи'),
emojiFile('kek.png', 'image/png'),
);
await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Имя эмодзи: 2–32 символа');
expect(uploadForm(fetchMock)).toBeUndefined();
});
it('показывает ошибки сервера понятным текстом', async () => {
const visitor = userEvent.setup();
installFetch(
routes({
extra: [
{
match: '/api/v1/guilds/g-1/emojis',
method: 'POST',
response: () => apiError('emoji.name_taken', 409),
},
],
}),
);
const { dialog } = await openEmojisSection();
await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'pepe');
await visitor.upload(
within(dialog).getByLabelText('Файл эмодзи'),
emojiFile('pepe.png', 'image/png'),
);
await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Эмодзи с таким именем уже есть.',
);
});
it('показывает ошибку лимита эмодзи', async () => {
const visitor = userEvent.setup();
installFetch(
routes({
extra: [
{
match: '/api/v1/guilds/g-1/emojis',
method: 'POST',
response: () => apiError('emojis.limit_reached', 422),
},
],
}),
);
const { dialog } = await openEmojisSection();
await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'kek');
await visitor.upload(
within(dialog).getByLabelText('Файл эмодзи'),
emojiFile('kek.png', 'image/png'),
);
await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'На сервере достигнут лимит эмодзи.',
);
});
it('переименовывает эмодзи через PATCH', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(
routes({
extra: [
{
match: '/api/v1/guilds/g-1/emojis/e-1',
method: 'PATCH',
response: () => json({ emoji: { ...pepe, name: 'pepe_happy' } }),
},
],
}),
);
const { dialog } = await openEmojisSection();
await visitor.click(
await within(dialog).findByRole('button', { name: 'Переименовать эмодзи :pepe:' }),
);
const modal = await screen.findByRole('dialog', { name: 'Переименовать эмодзи' });
const field = within(modal).getByLabelText('Имя эмодзи');
expect(field).toHaveValue('pepe');
await visitor.clear(field);
await visitor.type(field, 'pepe_happy');
await visitor.click(within(modal).getByRole('button', { name: 'Сохранить' }));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/emojis/e-1', method: 'PATCH' })?.body).toEqual({
name: 'pepe_happy',
});
});
});
it('удаляет эмодзи после подтверждения', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(
routes({
extra: [
{
match: '/api/v1/guilds/g-1/emojis/e-1',
method: 'DELETE',
response: () => json({ ok: true }),
},
],
}),
);
const { dialog } = await openEmojisSection();
await visitor.click(
await within(dialog).findByRole('button', { name: 'Удалить эмодзи :pepe:' }),
);
const modal = await screen.findByRole('dialog', { name: 'Удалить эмодзи?' });
expect(modal).toHaveTextContent(':pepe:');
await visitor.click(within(modal).getByRole('button', { name: 'Удалить' }));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/emojis/e-1', method: 'DELETE' })).toBeDefined();
});
});
it('показывает эмодзи из GUILD_EMOJIS_UPDATE, даже если список по REST не отдался', async () => {
// Под нагрузкой ручка списка отвечает 429 (AGENT.md 8.6), а раздел должен
// обновляться снапшотом из события шлюза, а не повтором запроса (7.12).
const gateway = installGatewaySocket(
makeReadySnapshot([{ id: 'g-1', name: 'Сервер', my_permissions: ['MANAGE_EMOJIS'] }]),
);
installFetch(
routes({
extra: [
{
match: '/api/v1/guilds/g-1/emojis',
method: 'GET',
response: () => apiError('ratelimit.hit', 429),
},
],
}),
);
renderApp('/settings/servers/g-1');
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
await gateway.greet();
const fresh = { id: 'e-9', name: 'fresh', file_id: '920', animated: false, url: '/files/920' };
gateway.dispatch('GUILD_EMOJIS_UPDATE', { guild_id: 'g-1', emojis: [pepe, fresh] });
expect(await within(dialog).findByTestId('guild-emoji-e-1')).toHaveTextContent(':pepe:');
expect(within(dialog).getByTestId('guild-emoji-e-9')).toHaveTextContent(':fresh:');
});
});