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