Files
glchat/web/tests/soundSettings.test.tsx
T

349 lines
14 KiB
TypeScript
Raw Normal View History

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();
});
});