6afbdf621b
`GUILD_EMOJIS_UPDATE` несёт набор эмодзи целиком, но раздел настроек читает
REST-кэш, а клиент на событие запрос инвалидировал. Под нагрузкой повторный
`GET /guilds/{id}/emojis` попадает под лимит частоты (429), а запрос списка идёт
с `retry: 0` — список оставался старым до перезахода. Тот же дефект, что у
раздела «Звуки» (коммит 4175b14), и та же правка: событие кладёт набор в кэш
запроса через `setQueryData`, а ошибка перезапроса больше не прячет уже
полученный список.
Регрессионный тест `web/tests/guildEmojisSettings.test.tsx` («показывает эмодзи
из GUILD_EMOJIS_UPDATE, даже если список по REST не отдался») падает на старом
коде.
385 lines
14 KiB
TypeScript
385 lines
14 KiB
TypeScript
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:');
|
||
});
|
||
});
|