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, findRequest, installFetch, installGatewaySocket, json, makeReadySnapshot, makeUser, recordedRequests, renderApp, requestUrl, resetStores, type FetchRoute, } from './helpers'; /** * Секция «Эмодзи» в настройках сервера (AGENT.md 7.12): список загруженных * эмодзи со счётчиком, загрузка с клиентскими проверками, переименование и * удаление. Лента и пикер видят изменения сразу: событие `GUILD_EMOJIS_UPDATE` * несёт набор целиком и попадает прямо в кэш запроса, а свои операции * инвалидируют список. */ const user = makeUser(); const pepe = { id: 'e-1', name: 'pepe', file_id: '900', animated: false, url: '/files/900' }; const parrot = { id: 'e-2', name: 'party_parrot', file_id: '901', animated: true, url: '/files/901', size_bytes: 2048, }; 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[]; emojis?: unknown[]; extra?: FetchRoute[]; uploadResponse?: () => Response; } /** Маршруты оболочки, карточки сервера и ручек эмодзи. */ function routes(options: RoutesOptions = {}) { const permissions = options.permissions ?? ['MANAGE_EMOJIS']; return [ ...(options.extra ?? []), { match: '/api/v1/guilds/g-1/emojis', method: 'GET', response: () => json({ emojis: options.emojis ?? [pepe, parrot] }), }, { match: '/api/v1/guilds/g-1/emojis', method: 'POST', response: options.uploadResponse ?? (() => json({ emoji: { ...pepe, id: 'e-3', name: 'kek' } })), }, { 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 openEmojisSection() { renderApp('/settings/servers/g-1'); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); const counter = await within(dialog).findByTestId('guild-emojis-counter'); return { dialog, counter }; } /** Тело 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/emojis'); }); return call?.[1]?.body as FormData | undefined; } /** Один эмодзи нужного размера и формата. */ function emojiFile(name = 'kek.png', type = 'image/png', 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 () => { installFetch(routes()); const { dialog } = await openEmojisSection(); const list = await within(dialog).findByTestId('guild-emojis'); expect(within(list).getByText(':pepe:')).toBeVisible(); expect(within(list).getByText(':party_parrot:')).toBeVisible(); expect(list.querySelector('img[src="/files/900"]')).not.toBeNull(); expect(list.querySelector('img[alt=":pepe:"]')).not.toBeNull(); expect(within(list).getByText('анимированный')).toBeVisible(); // Размер показываем, когда сервер его прислал. expect(within(list).getByText('2 KB')).toBeVisible(); expect(within(dialog).getByTestId('guild-emojis-counter')).toHaveTextContent('2 из 100'); }); it('скрыта без права MANAGE_EMOJIS', async () => { installFetch(routes({ permissions: ['MANAGE_GUILD'] })); renderApp('/settings/servers/g-1'); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); expect(await within(dialog).findByLabelText('Название сервера')).toBeVisible(); expect(within(dialog).queryByText('Эмодзи')).toBeNull(); expect(within(dialog).queryByTestId('guild-emojis-counter')).toBeNull(); }); it('загружает эмодзи multipart-запросом и перечитывает список', async () => { const visitor = userEvent.setup(); const fetchMock = installFetch(routes()); const { dialog } = await openEmojisSection(); await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'kek'); await visitor.upload( within(dialog).getByLabelText('Файл эмодзи'), emojiFile('kek.png', 'image/png'), ); await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' })); await waitFor(() => { expect(uploadForm(fetchMock)).toBeDefined(); }); const form = uploadForm(fetchMock); expect(form?.get('name')).toBe('kek'); expect((form?.get('file') as File | null)?.name).toBe('kek.png'); // Список перечитан: инвалидация после успешной загрузки. await waitFor(() => { expect( recordedRequests(fetchMock).filter( (request) => request.url.includes('/guilds/g-1/emojis') && request.method === 'GET', ).length, ).toBeGreaterThan(1); }); }); it('принимает файл перетаскиванием в область загрузки', async () => { const visitor = userEvent.setup(); const fetchMock = installFetch(routes()); const { dialog } = await openEmojisSection(); fireEvent.drop(within(dialog).getByTestId('emoji-dropzone'), { dataTransfer: { files: [emojiFile('drop.png', 'image/png')] }, }); // Имя файла видно в области загрузки — файл принят. expect(await within(dialog).findByText(/drop\.png/)).toBeVisible(); await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'drop'); await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' })); await waitFor(() => { expect(uploadForm(fetchMock)?.get('name')).toBe('drop'); }); }); it('не отправляет файл неподдерживаемого формата', async () => { const fetchMock = installFetch(routes()); const { dialog } = await openEmojisSection(); // Поле принимает только картинки, поэтому подкладываем файл напрямую — // как это делает перетаскивание в область загрузки. fireEvent.change(within(dialog).getByLabelText('Файл эмодзи'), { target: { files: [emojiFile('kek.txt', 'text/plain')] }, }); expect(await within(dialog).findByRole('alert')).toHaveTextContent( 'Поддерживаются PNG, JPEG, WebP и GIF.', ); expect(uploadForm(fetchMock)).toBeUndefined(); }); it('не отправляет файл больше 512 КБ', async () => { const visitor = userEvent.setup(); const fetchMock = installFetch(routes()); const { dialog } = await openEmojisSection(); await visitor.upload( within(dialog).getByLabelText('Файл эмодзи'), emojiFile('big.png', 'image/png', 512 * 1024 + 1), ); expect(await within(dialog).findByRole('alert')).toHaveTextContent( 'Файл больше допустимого размера.', ); expect(uploadForm(fetchMock)).toBeUndefined(); }); it('проверяет имя по формату сервера', async () => { const visitor = userEvent.setup(); const fetchMock = installFetch(routes()); const { dialog } = await openEmojisSection(); await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'x'); await visitor.upload( within(dialog).getByLabelText('Файл эмодзи'), emojiFile('kek.png', 'image/png'), ); await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' })); expect(await within(dialog).findByRole('alert')).toHaveTextContent('Имя эмодзи: 2–32 символа'); expect(uploadForm(fetchMock)).toBeUndefined(); }); it('показывает ошибки сервера понятным текстом', async () => { const visitor = userEvent.setup(); installFetch( routes({ extra: [ { match: '/api/v1/guilds/g-1/emojis', method: 'POST', response: () => apiError('emoji.name_taken', 409), }, ], }), ); const { dialog } = await openEmojisSection(); await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'pepe'); await visitor.upload( within(dialog).getByLabelText('Файл эмодзи'), emojiFile('pepe.png', 'image/png'), ); await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' })); expect(await within(dialog).findByRole('alert')).toHaveTextContent( 'Эмодзи с таким именем уже есть.', ); }); it('показывает ошибку лимита эмодзи', async () => { const visitor = userEvent.setup(); installFetch( routes({ extra: [ { match: '/api/v1/guilds/g-1/emojis', method: 'POST', response: () => apiError('emojis.limit_reached', 422), }, ], }), ); const { dialog } = await openEmojisSection(); await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'kek'); await visitor.upload( within(dialog).getByLabelText('Файл эмодзи'), emojiFile('kek.png', 'image/png'), ); await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' })); expect(await within(dialog).findByRole('alert')).toHaveTextContent( 'На сервере достигнут лимит эмодзи.', ); }); it('переименовывает эмодзи через PATCH', async () => { const visitor = userEvent.setup(); const fetchMock = installFetch( routes({ extra: [ { match: '/api/v1/guilds/g-1/emojis/e-1', method: 'PATCH', response: () => json({ emoji: { ...pepe, name: 'pepe_happy' } }), }, ], }), ); const { dialog } = await openEmojisSection(); await visitor.click( await within(dialog).findByRole('button', { name: 'Переименовать эмодзи :pepe:' }), ); const modal = await screen.findByRole('dialog', { name: 'Переименовать эмодзи' }); const field = within(modal).getByLabelText('Имя эмодзи'); expect(field).toHaveValue('pepe'); await visitor.clear(field); await visitor.type(field, 'pepe_happy'); await visitor.click(within(modal).getByRole('button', { name: 'Сохранить' })); await waitFor(() => { expect(findRequest(fetchMock, { url: '/emojis/e-1', method: 'PATCH' })?.body).toEqual({ name: 'pepe_happy', }); }); }); it('удаляет эмодзи после подтверждения', async () => { const visitor = userEvent.setup(); const fetchMock = installFetch( routes({ extra: [ { match: '/api/v1/guilds/g-1/emojis/e-1', method: 'DELETE', response: () => json({ ok: true }), }, ], }), ); const { dialog } = await openEmojisSection(); await visitor.click( await within(dialog).findByRole('button', { name: 'Удалить эмодзи :pepe:' }), ); const modal = await screen.findByRole('dialog', { name: 'Удалить эмодзи?' }); expect(modal).toHaveTextContent(':pepe:'); await visitor.click(within(modal).getByRole('button', { name: 'Удалить' })); await waitFor(() => { expect(findRequest(fetchMock, { url: '/emojis/e-1', method: 'DELETE' })).toBeDefined(); }); }); it('показывает эмодзи из GUILD_EMOJIS_UPDATE, даже если список по REST не отдался', async () => { // Под нагрузкой ручка списка отвечает 429 (AGENT.md 8.6), а раздел должен // обновляться снапшотом из события шлюза, а не повтором запроса (7.12). const gateway = installGatewaySocket( makeReadySnapshot([{ id: 'g-1', name: 'Сервер', my_permissions: ['MANAGE_EMOJIS'] }]), ); installFetch( routes({ extra: [ { match: '/api/v1/guilds/g-1/emojis', method: 'GET', response: () => apiError('ratelimit.hit', 429), }, ], }), ); renderApp('/settings/servers/g-1'); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); await gateway.greet(); const fresh = { id: 'e-9', name: 'fresh', file_id: '920', animated: false, url: '/files/920' }; gateway.dispatch('GUILD_EMOJIS_UPDATE', { guild_id: 'g-1', emojis: [pepe, fresh] }); expect(await within(dialog).findByTestId('guild-emoji-e-1')).toHaveTextContent(':pepe:'); expect(within(dialog).getByTestId('guild-emoji-e-9')).toHaveTextContent(':fresh:'); }); });