4175b1436e
`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.
349 lines
14 KiB
TypeScript
349 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,
|
||
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();
|
||
});
|
||
});
|