import { beforeEach, describe, expect, it, vi } from 'vitest'; import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { bodyOf, installFetch, requestUrl, json, makeUser, recordedRequests, renderApp, resetStores, type FetchRoute, } from './helpers'; /** * Оформление сервера в настройках (AGENT.md 7.4): иконка, баннер, splash и * акцентный цвет. Загрузка идёт multipart-запросом, сервер отвечает сервером — * карточка и список серверов обновляются без перезагрузки. */ const user = makeUser(); function guildDetail(overrides: Record = {}) { return { id: 'g-1', name: 'Сервер', description: '', owner_id: 'user-1', is_main: false, member_count: 1, roles: [], my_role_ids: [], my_permissions: ['MANAGE_GUILD'], ...overrides, }; } /** Маршруты оболочки и карточки сервера; оформление отвечает тем же сервером. */ function routes(extra: FetchRoute[] = [], guild = guildDetail()): FetchRoute[] { return [ ...extra, { match: '/api/v1/guilds/g-1/appearance/', method: 'POST', response: () => json({ guild: guildDetail({ icon_file_id: '900' }) }), }, { match: '/api/v1/guilds/g-1/appearance/', method: 'DELETE', response: () => json({ guild: guildDetail() }), }, { match: '/api/v1/guilds/g-1', method: 'PATCH', response: () => json({ guild: guildDetail({ accent_color: 0x5865f2 }) }), }, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [guild] }) }, { 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 }) }, ]; } async function openAppearance(guild = guildDetail()) { const fetchMock = installFetch(routes([], guild)); renderApp('/settings/servers/g-1'); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); const section = await within(dialog).findByTestId('guild-appearance'); return { dialog, section, fetchMock }; } /** Файл изображения нужного размера. */ function imageFile(name = 'icon.png', type = 'image/png', size = 1024): File { const file = new File(['картинка'], name, { type }); Object.defineProperty(file, 'size', { value: size }); return file; } beforeEach(() => { resetStores(); // jsdom не умеет object URL: подменяем, иначе превью выбранного файла падает. vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview'); vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); }); describe('настройки сервера: оформление', () => { it('показывает иконку, баннер и splash с превью', async () => { const { section } = await openAppearance( guildDetail({ icon_file_id: '900', banner_file_id: '901', splash_file_id: '902', }), ); expect( within(section).getByTestId('appearance-icon-preview').querySelector('img'), ).toHaveAttribute('src', '/files/900'); expect( within(section).getByTestId('appearance-banner-preview').querySelector('img'), ).toHaveAttribute('src', '/files/901'); expect( within(section).getByTestId('appearance-splash-preview').querySelector('img'), ).toHaveAttribute('src', '/files/902'); }); it('загружает выбранный файл multipart-запросом', async () => { const { section, fetchMock } = await openAppearance(); const input = within(section).getByTestId('appearance-icon-input'); const file = imageFile(); await userEvent.upload(input, file); await waitFor(() => { const call = fetchMock.mock.calls .filter( ([input, init]) => requestUrl(input).includes('/appearance/icon') && (init?.method ?? 'GET').toUpperCase() === 'POST', ) .at(-1); expect(call).toBeDefined(); // Тело multipart не разбирается тестовым помощником: смотрим FormData. const body = call?.[1]?.body; expect(body).toBeInstanceOf(FormData); expect((body as FormData).get('file')).toBe(file); }); // Превью обновляется из ответа сервера. await waitFor(() => { expect( within(section).getByTestId('appearance-icon-preview').querySelector('img'), ).toHaveAttribute('src', '/files/900'); }); }); it('удаляет оформление и очищает превью', async () => { const { section, fetchMock } = await openAppearance(guildDetail({ banner_file_id: '901' })); await userEvent.click(within(section).getByTestId('appearance-banner-remove')); await waitFor(() => { const request = recordedRequests(fetchMock) .filter((item) => item.url.includes('/appearance/banner')) .at(-1); expect(request?.method).toBe('DELETE'); }); await waitFor(() => { expect( within(section).getByTestId('appearance-banner-preview').querySelector('img'), ).toBeNull(); }); }); it('применяет акцентный цвет и снимает его', async () => { const { section, fetchMock } = await openAppearance(); await userEvent.click(within(section).getByTestId(`appearance-accent-${0x5865f2}`)); await waitFor(() => { const request = recordedRequests(fetchMock) .filter((item) => item.method === 'PATCH' && item.url.endsWith('/guilds/g-1')) .at(-1); expect(bodyOf(request)).toEqual({ accent_color: 0x5865f2 }); }); await userEvent.click(within(section).getByTestId('appearance-accent-0')); await waitFor(() => { const request = recordedRequests(fetchMock) .filter((item) => item.method === 'PATCH' && item.url.endsWith('/guilds/g-1')) .at(-1); expect(bodyOf(request)).toEqual({ accent_color: 0 }); }); }); it('не отправляет файл, который не является изображением', async () => { const { section, fetchMock } = await openAppearance(); // applyAccept: false — проверяем серверную проверку типа, а не атрибут accept. await userEvent.upload( within(section).getByTestId('appearance-icon-input'), new File(['текст'], 'note.txt', { type: 'text/plain' }), { applyAccept: false }, ); expect(await within(section).findByTestId('appearance-error')).toHaveTextContent( 'Нужен файл изображения', ); expect( recordedRequests(fetchMock).filter((item) => item.url.includes('/appearance/icon')), ).toHaveLength(0); }); it('скрывает секцию без права MANAGE_GUILD', async () => { const guild = guildDetail({ owner_id: 'user-9', my_permissions: [] }); installFetch(routes([], guild)); renderApp('/settings/servers/g-1'); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); await within(dialog).findByText('Общее'); expect(within(dialog).queryByTestId('guild-appearance')).toBeNull(); }); });