Files
glchat/web/tests/soundSettings.test.tsx
T
grendervill 4175b1436e fix(web): свежий звук сервера виден в настройках и при ошибке списка
`GUILD_SOUNDS_UPDATE` приносит набор звуков целиком, но раздел «Звуки» читает
REST-кэш: клиент инвалидировал запрос вместо того, чтобы положить в кэш сам
payload события. Под нагрузкой повторный `GET /guilds/{id}/sounds` попадает
под лимит частоты (429), а запрос списка идёт с `retry: 0` — список оставался
старым, и новый звук не появлялся в разделе. Так падал пункт 5 матрицы 11.6 в
полной прогонке (build/e2e-all-run8.log, run9): в логах стенда видно
`POST .../sounds 200`, следом `GET .../sounds 200` (первый звук) и
`GET .../sounds 429` (второй) — без единой повторной попытки.

Теперь событие пишет список в кэш запроса, а ошибка перезапроса не прячет уже
полученный список: раздел показывает актуальный набор из снапшота.

Тест: web/tests/soundSettings.test.tsx — звук из `GUILD_SOUNDS_UPDATE`
появляется, когда список по REST отвечает 429.
2026-09-26 15:06:26 +03:00

349 lines
14 KiB
TypeScript
Raw 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,
installFetch,
installGatewaySocket,
json,
makeReadySnapshot,
makeUser,
renderApp,
requestUrl,
resetStores,
type FetchRoute,
} from './helpers';
import { soundRoutes } from './soundRoutes';
import type { GuildSound } from '@/api/sounds';
import { SOUND_LIMIT_PER_KIND } from '@/api/sounds';
/**
* Секция «Звуки» в настройках сервера (AGENT.md 7.13): список саундборда и
* палитры со счётчиками, загрузка с выбором события, переименование и
* удаление. Снапшот обновляется точечно, запросы инвалидируются — попап
* саундборда видит изменения сразу.
*/
const user = makeUser();
const airhorn: GuildSound = {
id: 's-1',
name: 'airhorn',
file_id: '900',
kind: 'soundboard',
emoji: '📣',
};
const quack: GuildSound = { id: 's-2', name: 'quack', file_id: '901', kind: 'soundboard' };
const joinSound: GuildSound = {
id: 'u-1',
name: 'hello',
file_id: '910',
kind: 'ui',
event: 'member_join',
};
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[];
sounds?: GuildSound[];
extra?: FetchRoute[];
uploadError?: { code: string; status: number };
}
/** Маршруты оболочки, карточки сервера и ручек звуков. */
function routes(options: RoutesOptions = {}) {
const permissions = options.permissions ?? ['MANAGE_SOUNDS'];
const sounds = soundRoutes({
sounds: options.sounds ?? [airhorn, quack, joinSound],
...(options.uploadError === undefined ? {} : { uploadError: options.uploadError }),
});
return [
...(options.extra ?? []),
...sounds.routes,
{
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 openSettings(options: RoutesOptions = {}) {
const fetchMock = installFetch(routes(options));
renderApp('/settings/servers/g-1');
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
return { dialog, fetchMock };
}
/** Открывает настройки сервера и дожидается загруженной секции «Звуки». */
async function openSoundsSection(options: RoutesOptions = {}) {
const { dialog, fetchMock } = await openSettings(options);
const counter = await within(dialog).findByTestId('guild-sounds-counter');
// Список приходит запросом: дожидаемся его, иначе счётчик ещё нулевой.
await within(dialog).findByTestId('guild-sounds');
return { dialog, counter, fetchMock };
}
/** Тело 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/sounds');
});
return call?.[1]?.body as FormData | undefined;
}
/** URL запроса загрузки: по нему проверяют `kind`. */
function uploadUrl(fetchMock: ReturnType<typeof installFetch>): string {
const call = fetchMock.mock.calls.find(([input, init]) => {
const method = (init?.method ?? 'GET').toUpperCase();
return method === 'POST' && requestUrl(input).includes('/guilds/g-1/sounds');
});
return call === undefined ? '' : requestUrl(call[0]);
}
/** Один звук нужного размера и формата. */
function soundFile(name = 'airhorn.mp3', type = 'audio/mpeg', 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 () => {
const { dialog, counter } = await openSoundsSection();
expect(counter).toHaveTextContent('2 из 30');
const list = await within(dialog).findByTestId('guild-sounds');
expect(within(list).getByTestId('guild-sound-s-1')).toHaveTextContent('airhorn');
expect(within(list).queryByTestId('guild-sound-u-1')).toBeNull();
await userEvent.click(within(dialog).getByTestId('guild-sounds-tab-ui'));
expect(within(dialog).getByTestId('guild-sounds-counter')).toHaveTextContent('1 из 30');
const palette = within(dialog).getByTestId('guild-sounds');
expect(within(palette).getByTestId('guild-sound-u-1')).toHaveTextContent('hello');
// У звука палитры видно привязанное событие.
expect(within(palette).getByTestId('guild-sound-u-1')).toHaveTextContent(
'Участник присоединился',
);
});
it('скрыта без права MANAGE_SOUNDS', async () => {
const { dialog } = await openSettings({ permissions: ['VIEW_GUILD'] });
expect(within(dialog).queryByTestId('guild-sounds-counter')).toBeNull();
});
it('показывает звук из GUILD_SOUNDS_UPDATE, даже если список по REST не отдался', async () => {
// Под нагрузкой ручка списка отвечает 429 (AGENT.md 8.6), а раздел должен
// обновляться снапшотом из события шлюза, а не повтором запроса (7.13).
const gateway = installGatewaySocket(
makeReadySnapshot([{ id: 'g-1', name: 'Сервер', my_permissions: ['MANAGE_SOUNDS'] }]),
);
installFetch(
routes({
extra: [
{
match: '/api/v1/guilds/g-1/sounds',
response: () => apiError('ratelimit.hit', 429),
},
],
}),
);
renderApp('/settings/servers/g-1');
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
await gateway.greet();
const fresh: GuildSound = { id: 's-3', name: 'fresh', file_id: '920', kind: 'soundboard' };
gateway.dispatch('GUILD_SOUNDS_UPDATE', { guild_id: 'g-1', sounds: [airhorn, fresh] });
expect(await within(dialog).findByTestId('guild-sound-s-1')).toHaveTextContent('airhorn');
expect(within(dialog).getByTestId('guild-sound-s-3')).toHaveTextContent('fresh');
});
it('загружает звук палитры: kind в URL, событие и имя в multipart', async () => {
const { dialog, fetchMock } = await openSoundsSection();
await userEvent.click(within(dialog).getByTestId('guild-sounds-tab-ui'));
await userEvent.type(within(dialog).getByLabelText('Имя звука'), 'notify');
await userEvent.selectOptions(within(dialog).getByTestId('guild-sounds-event'), 'mention');
fireEvent.change(within(dialog).getByLabelText('Файл звука'), {
target: { files: [soundFile()] },
});
await userEvent.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
await waitFor(() => {
expect(uploadForm(fetchMock)).toBeDefined();
});
expect(uploadUrl(fetchMock)).toContain('kind=ui');
const form = uploadForm(fetchMock);
expect(form?.get('name')).toBe('notify');
expect(form?.get('event')).toBe('mention');
expect(form?.get('file')).toBeInstanceOf(File);
});
it('загружает звук саундборда без события', async () => {
const { dialog, fetchMock } = await openSoundsSection();
await userEvent.type(within(dialog).getByLabelText('Имя звука'), 'horn2');
await userEvent.type(within(dialog).getByLabelText('Эмодзи'), '📢');
fireEvent.change(within(dialog).getByLabelText('Файл звука'), {
target: { files: [soundFile('second.ogg', 'audio/ogg')] },
});
await userEvent.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
await waitFor(() => {
expect(uploadForm(fetchMock)).toBeDefined();
});
expect(uploadUrl(fetchMock)).toContain('kind=soundboard');
expect(uploadForm(fetchMock)?.get('event')).toBeNull();
expect(uploadForm(fetchMock)?.get('emoji')).toBe('📢');
});
it('принимает файл перетаскиванием в область загрузки', async () => {
const { dialog, fetchMock } = await openSoundsSection();
fireEvent.drop(within(dialog).getByTestId('sound-dropzone'), {
dataTransfer: { files: [soundFile('drop.wav', 'audio/wav')] },
});
await userEvent.type(within(dialog).getByLabelText('Имя звука'), 'dropped');
await userEvent.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
await waitFor(() => {
expect(uploadForm(fetchMock)?.get('name')).toBe('dropped');
});
});
it('не отправляет файл неподдерживаемого формата', async () => {
const { dialog, fetchMock } = await openSoundsSection();
fireEvent.change(within(dialog).getByLabelText('Файл звука'), {
target: { files: [soundFile('sound.txt', 'text/plain')] },
});
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Поддерживаются MP3, OGG, WAV, WebM и M4A.',
);
expect(uploadForm(fetchMock)).toBeUndefined();
});
it('не отправляет файл больше 512 КБ', async () => {
const { dialog, fetchMock } = await openSoundsSection();
fireEvent.change(within(dialog).getByLabelText('Файл звука'), {
target: { files: [soundFile('big.mp3', 'audio/mpeg', 512 * 1024 + 1)] },
});
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Файл больше допустимого размера.',
);
expect(uploadForm(fetchMock)).toBeUndefined();
});
it('проверяет имя по формату сервера', async () => {
const { dialog, fetchMock } = await openSoundsSection();
await userEvent.type(within(dialog).getByLabelText('Имя звука'), 'плохое имя');
fireEvent.change(within(dialog).getByLabelText('Файл звука'), {
target: { files: [soundFile()] },
});
await userEvent.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Имя звука: 2–32 символа, латиница, цифры и «_».',
);
expect(uploadForm(fetchMock)).toBeUndefined();
});
it('показывает ошибку лимита саундборда', async () => {
const many: GuildSound[] = Array.from({ length: SOUND_LIMIT_PER_KIND }, (_value, index) => ({
id: `s-${String(index)}`,
name: `sound_${String(index)}`,
file_id: `${String(1000 + index)}`,
kind: 'soundboard',
}));
const { dialog } = await openSoundsSection({ sounds: many });
expect(within(dialog).getByTestId('guild-sounds-counter')).toHaveTextContent('30 из 30');
expect(dialog).toHaveTextContent('Достигнут лимит звуков этого вида.');
expect(within(dialog).getByRole('button', { name: 'Загрузить' })).toBeDisabled();
});
it('переименовывает звук через PATCH', async () => {
const { dialog, fetchMock } = await openSoundsSection();
await userEvent.click(within(dialog).getByLabelText('Переименовать звук airhorn'));
// В окне переименования своё поле имени: ищем его внутри модального окна.
const modal = await screen.findByRole('dialog', { name: 'Переименовать звук' });
const field = within(modal).getByLabelText('Имя звука');
await userEvent.clear(field);
await userEvent.type(field, 'airhorn2');
await userEvent.click(within(modal).getByRole('button', { name: 'Сохранить' }));
await waitFor(() => {
const request = fetchMock.mock.calls.find(
([input, init]) =>
(init?.method ?? 'GET').toUpperCase() === 'PATCH' &&
requestUrl(input).includes('/sounds/s-1'),
);
expect(request).toBeDefined();
});
});
it('удаляет звук после подтверждения', async () => {
const { dialog, fetchMock } = await openSoundsSection();
await userEvent.click(within(dialog).getByLabelText('Удалить звук quack'));
await userEvent.click(await screen.findByRole('button', { name: 'Удалить' }));
await waitFor(() => {
const request = fetchMock.mock.calls.find(
([input, init]) =>
(init?.method ?? 'GET').toUpperCase() === 'DELETE' &&
requestUrl(input).includes('/sounds/s-2'),
);
expect(request).toBeDefined();
});
});
it('показывает ошибку сервера понятным текстом', async () => {
const { dialog } = await openSoundsSection({
uploadError: { code: 'sound.name_taken', status: 409 },
});
await userEvent.type(within(dialog).getByLabelText('Имя звука'), 'airhorn');
fireEvent.change(within(dialog).getByLabelText('Файл звука'), {
target: { files: [soundFile()] },
});
await userEvent.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
expect(await within(dialog).findByText('Звук с таким именем уже есть.')).toBeVisible();
});
});