Files
glchat/web/tests/guildAppearance.test.tsx
T
grendervill 6882516340 feat(web): оформление сервера в настройках, рейке и приглашении
Фаза 4 (AGENT.md 7.4): интерфейс для иконки, баннера, splash и акцентного цвета.

- секция «Оформление» в настройках сервера: загрузка и удаление иконки,
  баннера и splash с превью, проверка типа и размера на клиенте, пресеты
  акцентного цвета;
- иконка сервера показывается в рейке (заглушка-буквы остаётся, если файла
  нет), акцентный цвет переопределяет `--color-accent` внутри приложения и
  снимается при значении 0;
- предпросмотр приглашения показывает splash фоном и иконку сервера;
- ответ сервера обновляет карточку и список серверов — оформление видно без
  перезагрузки;
- тесты: превью всех трёх видов, multipart-загрузка, удаление, применение и
  снятие акцента, отказ для не-изображения, скрытие секции без MANAGE_GUILD.
2026-09-20 23:34:05 +03:00

207 lines
7.6 KiB
TypeScript

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<string, unknown> = {}) {
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();
});
});