319 lines
13 KiB
TypeScript
319 lines
13 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 {
|
||
|
|
installFetch,
|
||
|
|
json,
|
||
|
|
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('загружает звук палитры: 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();
|
||
|
|
});
|
||
|
|
});
|