feat(web): оформление сервера в настройках, рейке и приглашении

Фаза 4 (AGENT.md 7.4): интерфейс для иконки, баннера, splash и акцентного цвета.

- секция «Оформление» в настройках сервера: загрузка и удаление иконки,
  баннера и splash с превью, проверка типа и размера на клиенте, пресеты
  акцентного цвета;
- иконка сервера показывается в рейке (заглушка-буквы остаётся, если файла
  нет), акцентный цвет переопределяет `--color-accent` внутри приложения и
  снимается при значении 0;
- предпросмотр приглашения показывает splash фоном и иконку сервера;
- ответ сервера обновляет карточку и список серверов — оформление видно без
  перезагрузки;
- тесты: превью всех трёх видов, multipart-загрузка, удаление, применение и
  снятие акцента, отказ для не-изображения, скрытие секции без MANAGE_GUILD.
This commit is contained in:
2026-09-20 23:34:05 +03:00
parent 02a5b99418
commit 6882516340
11 changed files with 663 additions and 4 deletions
+41
View File
@@ -96,6 +96,47 @@ export async function updateGuildMember(
); );
} }
/** Виды оформления сервера (AGENT.md 7.4). */
export type GuildAppearanceKind = 'icon' | 'banner' | 'splash';
/**
* Загрузка оформления сервера: multipart с полем `file`. Сервер принимает
* только изображения и отвечает обновлённым сервером.
*/
export async function uploadGuildAppearance(
guildId: string,
kind: GuildAppearanceKind,
file: File,
): Promise<Guild> {
const form = new FormData();
form.append('file', file);
const payload = await request<{ guild: Guild }>(
`/guilds/${encodeURIComponent(guildId)}/appearance/${kind}`,
{ method: 'POST', form },
);
return payload.guild;
}
export async function deleteGuildAppearance(
guildId: string,
kind: GuildAppearanceKind,
): Promise<Guild> {
const payload = await request<{ guild: Guild }>(
`/guilds/${encodeURIComponent(guildId)}/appearance/${kind}`,
{ method: 'DELETE' },
);
return payload.guild;
}
/** Акцентный цвет сервера: 0 возвращает цвет темы. */
export async function updateGuildAccent(guildId: string, accentColor: number): Promise<Guild> {
const payload = await request<{ guild: Guild }>(`/guilds/${encodeURIComponent(guildId)}`, {
method: 'PATCH',
body: { accent_color: accentColor },
});
return payload.guild;
}
/** Удаление сервера: доступно владельцу и администратору инстанса. */ /** Удаление сервера: доступно владельцу и администратору инстанса. */
export async function deleteGuild(guildId: string): Promise<void> { export async function deleteGuild(guildId: string): Promise<void> {
await request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}`, { method: 'DELETE' }); await request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}`, { method: 'DELETE' });
+16
View File
@@ -12,6 +12,10 @@ export interface InviteGuildPreview {
id: string; id: string;
name: string; name: string;
icon_file_id?: string; icon_file_id?: string;
/** Оформление сервера для страницы приглашения (AGENT.md 7.4). */
banner_file_id?: string;
splash_file_id?: string;
accent_color?: number;
description?: string; description?: string;
member_count: number; member_count: number;
is_member: boolean; is_member: boolean;
@@ -84,6 +88,18 @@ export async function fetchInvite(code: string, signal?: AbortSignal): Promise<I
}, },
}; };
const icon = asString(guild['icon_file_id']); const icon = asString(guild['icon_file_id']);
const banner = asString(guild['banner_file_id']);
const splash = asString(guild['splash_file_id']);
const accent = typeof guild['accent_color'] === 'number' ? guild['accent_color'] : undefined;
if (banner !== undefined) {
preview.guild.banner_file_id = banner;
}
if (splash !== undefined) {
preview.guild.splash_file_id = splash;
}
if (accent !== undefined && accent !== 0) {
preview.guild.accent_color = accent;
}
const description = asString(guild['description']); const description = asString(guild['description']);
if (icon !== undefined) { if (icon !== undefined) {
preview.guild.icon_file_id = icon; preview.guild.icon_file_id = icon;
+7
View File
@@ -91,6 +91,10 @@ export interface Guild {
id: string; id: string;
name: string; name: string;
icon_file_id?: string; icon_file_id?: string;
banner_file_id?: string;
splash_file_id?: string;
/** Акцентный цвет сервера, 0 — цвет темы. */
accent_color?: number;
owner_id: string; owner_id: string;
is_main: boolean; is_main: boolean;
description: string; description: string;
@@ -105,6 +109,9 @@ export interface GuildSummary {
id: string; id: string;
name: string; name: string;
icon_file_id?: string; icon_file_id?: string;
banner_file_id?: string;
splash_file_id?: string;
accent_color?: number;
owner_id: string; owner_id: string;
is_main: boolean; is_main: boolean;
member_count: number; member_count: number;
+8 -1
View File
@@ -106,8 +106,15 @@ export function InviteJoin({ className = '' }: InviteJoinProps) {
{preview.data === undefined ? null : ( {preview.data === undefined ? null : (
<div <div
className="mt-3 flex items-center gap-3 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-2" className="mt-3 flex items-center gap-3 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 bg-cover bg-center px-3 py-2"
data-testid="invite-preview" data-testid="invite-preview"
style={
preview.data.guild.splash_file_id === undefined
? undefined
: {
backgroundImage: `url(/files/${encodeURIComponent(preview.data.guild.splash_file_id)})`,
}
}
> >
<Avatar <Avatar
name={preview.data.guild.name} name={preview.data.guild.name}
+23
View File
@@ -950,6 +950,29 @@
"apply": "Apply", "apply": "Apply",
"reset": "Reset", "reset": "Reset",
"empty": "No entries." "empty": "No entries."
},
"appearance": {
"title": "Appearance",
"description": "Server icon, banner, invite splash and accent color.",
"icon": "Server icon",
"iconHint": "Square image shown in the server list.",
"banner": "Banner",
"bannerHint": "Wide image for the server header.",
"splash": "Invite splash",
"splashHint": "Background of the invite page.",
"accent": "Accent color",
"accentHint": "Color of buttons and highlights inside the server.",
"accentDefault": "Theme color",
"accentBlurple": "Blue",
"accentGreen": "Green",
"accentOrange": "Orange",
"accentRed": "Red",
"accentPurple": "Purple",
"upload": "Upload",
"remove": "Remove",
"empty": "none",
"notImage": "An image file is required (PNG, JPEG, GIF, WebP).",
"tooLarge": "The file is larger than 8 MB."
} }
}, },
"navLabel": "Settings sections", "navLabel": "Settings sections",
+23
View File
@@ -950,6 +950,29 @@
"apply": "Показать", "apply": "Показать",
"reset": "Сбросить", "reset": "Сбросить",
"empty": "Записей нет." "empty": "Записей нет."
},
"appearance": {
"title": "Оформление",
"description": "Иконка, баннер, splash страницы приглашения и акцентный цвет сервера.",
"icon": "Иконка сервера",
"iconHint": "Квадратная картинка: показывается в списке серверов.",
"banner": "Баннер",
"bannerHint": "Широкая картинка для шапки сервера.",
"splash": "Splash приглашения",
"splashHint": "Фон страницы приглашения по ссылке.",
"accent": "Акцентный цвет",
"accentHint": "Цвет кнопок и выделений внутри сервера.",
"accentDefault": "Цвет темы",
"accentBlurple": "Синий",
"accentGreen": "Зелёный",
"accentOrange": "Оранжевый",
"accentRed": "Красный",
"accentPurple": "Фиолетовый",
"upload": "Загрузить",
"remove": "Удалить",
"empty": "нет",
"notImage": "Нужен файл изображения (PNG, JPEG, GIF, WebP).",
"tooLarge": "Файл больше 8 МБ."
} }
}, },
"navLabel": "Пункты настроек", "navLabel": "Пункты настроек",
+21 -1
View File
@@ -141,12 +141,32 @@ export default function AppLayout() {
}, [fallbackGuild.data, upsertGuild]); }, [fallbackGuild.data, upsertGuild]);
const guildList = useMemo( const guildList = useMemo(
() => guilds.map((guild) => ({ id: guild.id, name: guild.name })), () =>
guilds.map((guild) => ({
id: guild.id,
name: guild.name,
...(guild.icon_file_id === undefined ? {} : { icon_file_id: guild.icon_file_id }),
})),
[guilds], [guilds],
); );
const canCreate = instance.data?.allow_guild_creation !== false; const canCreate = instance.data?.allow_guild_creation !== false;
// Акцент сервера переопределяет цвет темы внутри приложения (AGENT.md 7.4).
// Значение 0 означает «цвет темы», тогда переменная снимается.
const accent = guilds.find((item) => item.id === guildId)?.accent_color ?? 0;
useEffect(() => {
const root = document.documentElement;
if (accent === 0) {
root.style.removeProperty('--color-accent');
return;
}
root.style.setProperty('--color-accent', `#${accent.toString(16).padStart(6, '0')}`);
return () => {
root.style.removeProperty('--color-accent');
};
}, [accent]);
return ( return (
<div className="flex h-full min-h-full flex-col"> <div className="flex h-full min-h-full flex-col">
<a <a
+8 -2
View File
@@ -4,7 +4,7 @@ import { NavLink } from 'react-router';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
interface GuildRailProps { interface GuildRailProps {
guilds: { id: string; name: string }[]; guilds: { id: string; name: string; icon_file_id?: string | undefined }[];
selectedGuildId: string | null; selectedGuildId: string | null;
canCreate: boolean; canCreate: boolean;
onCreate: () => void; onCreate: () => void;
@@ -70,7 +70,13 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil
: 'hover:-translate-y-0.5 hover:rounded-[var(--radius-lg)] active:translate-y-0' : 'hover:-translate-y-0.5 hover:rounded-[var(--radius-lg)] active:translate-y-0'
}`} }`}
> >
<Avatar name={guild.name} seed={guild.id} size="lg" shape="square" /> <Avatar
name={guild.name}
seed={guild.id}
fileId={guild.icon_file_id}
size="lg"
shape="square"
/>
</NavLink> </NavLink>
</li> </li>
); );
@@ -0,0 +1,308 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
deleteGuildAppearance,
guildQueryKey,
updateGuildAccent,
uploadGuildAppearance,
type GuildAppearanceKind,
} from '@/api/guilds';
import { myGuildsQueryKey } from '@/api/users';
import { Avatar } from '@/components/ui/Avatar';
import { Button } from '@/components/ui/primitives';
import { avatarUrl } from '@/lib/identity';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
import { SettingsSection } from '@/pages/settings/SettingsSection';
import type { Guild } from '@/api/types';
/** Пресеты акцентного цвета: 0 — цвет темы. */
const ACCENT_PRESETS: { value: number; labelKey: string }[] = [
{ value: 0, labelKey: 'settings.servers.appearance.accentDefault' },
{ value: 0x5865f2, labelKey: 'settings.servers.appearance.accentBlurple' },
{ value: 0x2ecc71, labelKey: 'settings.servers.appearance.accentGreen' },
{ value: 0xe67e22, labelKey: 'settings.servers.appearance.accentOrange' },
{ value: 0xe74c3c, labelKey: 'settings.servers.appearance.accentRed' },
{ value: 0x9b59b6, labelKey: 'settings.servers.appearance.accentPurple' },
];
/** Максимальный размер файла оформления: сервер отвечает `file.too_large`. */
export const GUILD_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
interface GuildAppearanceSectionProps {
guild: Guild;
canEdit: boolean;
}
/**
* «Оформление»: иконка, баннер, splash для страницы приглашения и акцентный
* цвет сервера (AGENT.md 7.4). Анимированные GIF/APNG/WebP не перекодируются,
* поэтому показываются с анимацией как есть.
*/
export function GuildAppearanceSection({ guild, canEdit }: GuildAppearanceSectionProps) {
const { t } = useTranslation();
const describeError = useApiErrorMessage();
const queryClient = useQueryClient();
const [previews, setPreviews] = useState<Partial<Record<GuildAppearanceKind, string>>>({});
const [error, setError] = useState<unknown>(null);
// Три отдельных ref: правило react-hooks/refs запрещает читать их из объекта
// во время рендера.
const iconInput = useRef<HTMLInputElement | null>(null);
const bannerInput = useRef<HTMLInputElement | null>(null);
const splashInput = useRef<HTMLInputElement | null>(null);
const applyGuild = (updated: Guild): void => {
// Карточка сервера и его список в рейке обновляются сразу: оформление
// видно без перезагрузки (AGENT.md 11.6).
queryClient.setQueryData(guildQueryKey(guild.id), updated);
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
};
const upload = useMutation({
mutationFn: ({ kind, file }: { kind: GuildAppearanceKind; file: File }) =>
uploadGuildAppearance(guild.id, kind, file),
onSuccess: (updated, variables) => {
setError(null);
setPreviews((current) => ({ ...current, [variables.kind]: undefined }));
applyGuild(updated);
},
onError: (cause) => setError(cause),
});
const remove = useMutation({
mutationFn: (kind: GuildAppearanceKind) => deleteGuildAppearance(guild.id, kind),
onSuccess: (updated) => {
setError(null);
applyGuild(updated);
},
onError: (cause) => setError(cause),
});
const accent = useMutation({
mutationFn: (value: number) => updateGuildAccent(guild.id, value),
onSuccess: (updated) => {
setError(null);
applyGuild(updated);
},
onError: (cause) => setError(cause),
});
if (!canEdit) {
return null;
}
const pick = (kind: GuildAppearanceKind, file: File | undefined): void => {
if (file === undefined) {
return;
}
if (!file.type.startsWith('image/')) {
setError(new Error(t('settings.servers.appearance.notImage')));
return;
}
if (file.size > GUILD_IMAGE_MAX_BYTES) {
setError(new Error(t('settings.servers.appearance.tooLarge')));
return;
}
setPreviews((current) => ({ ...current, [kind]: URL.createObjectURL(file) }));
upload.mutate({ kind, file });
};
const pending = upload.isPending || remove.isPending || accent.isPending;
return (
<SettingsSection
title={t('settings.servers.appearance.title')}
description={t('settings.servers.appearance.description')}
>
<div className="mt-4 flex flex-col gap-4" data-testid="guild-appearance">
<AppearanceRow
testId="appearance-icon"
label={t('settings.servers.appearance.icon')}
hint={t('settings.servers.appearance.iconHint')}
fileId={guild.icon_file_id}
preview={previews.icon}
name={guild.name}
seed={guild.id}
shape="square"
disabled={pending}
onPick={() => iconInput.current?.click()}
onRemove={() => remove.mutate('icon')}
/>
<input
ref={iconInput}
type="file"
accept="image/*"
className="hidden"
data-testid="appearance-icon-input"
onChange={(event) => {
pick('icon', event.target.files?.[0]);
event.target.value = '';
}}
/>
<AppearanceRow
testId="appearance-banner"
label={t('settings.servers.appearance.banner')}
hint={t('settings.servers.appearance.bannerHint')}
fileId={guild.banner_file_id}
preview={previews.banner}
name={guild.name}
seed={`${guild.id}-banner`}
shape="wide"
disabled={pending}
onPick={() => bannerInput.current?.click()}
onRemove={() => remove.mutate('banner')}
/>
<input
ref={bannerInput}
type="file"
accept="image/*"
className="hidden"
data-testid="appearance-banner-input"
onChange={(event) => {
pick('banner', event.target.files?.[0]);
event.target.value = '';
}}
/>
<AppearanceRow
testId="appearance-splash"
label={t('settings.servers.appearance.splash')}
hint={t('settings.servers.appearance.splashHint')}
fileId={guild.splash_file_id}
preview={previews.splash}
name={guild.name}
seed={`${guild.id}-splash`}
shape="wide"
disabled={pending}
onPick={() => splashInput.current?.click()}
onRemove={() => remove.mutate('splash')}
/>
<input
ref={splashInput}
type="file"
accept="image/*"
className="hidden"
data-testid="appearance-splash-input"
onChange={(event) => {
pick('splash', event.target.files?.[0]);
event.target.value = '';
}}
/>
<div>
<p className="text-sm font-medium">{t('settings.servers.appearance.accent')}</p>
<p className="text-xs text-fg-muted">{t('settings.servers.appearance.accentHint')}</p>
<div className="mt-2 flex flex-wrap items-center gap-2" data-testid="appearance-accent">
{ACCENT_PRESETS.map((preset) => {
const selected = (guild.accent_color ?? 0) === preset.value;
return (
<button
key={preset.labelKey}
type="button"
disabled={pending}
aria-pressed={selected}
aria-label={t(preset.labelKey)}
title={t(preset.labelKey)}
data-testid={`appearance-accent-${preset.value}`}
onClick={() => accent.mutate(preset.value)}
className={`h-8 w-8 rounded-full border transition-transform duration-[var(--duration-fast)] active:scale-95 ${
selected ? 'border-fg ring-2 ring-accent' : 'border-border/60'
}`}
style={
preset.value === 0
? { background: 'var(--color-accent)' }
: { background: `#${preset.value.toString(16).padStart(6, '0')}` }
}
/>
);
})}
</div>
</div>
{error === null || error === undefined ? null : (
<p className="text-sm text-danger" role="alert" data-testid="appearance-error">
{error instanceof Error ? error.message : describeError(error)}
</p>
)}
</div>
</SettingsSection>
);
}
interface AppearanceRowProps {
testId: string;
label: string;
hint: string;
fileId: string | undefined;
preview: string | undefined;
name: string;
seed: string;
shape: 'square' | 'wide';
disabled: boolean;
onPick: () => void;
onRemove: () => void;
}
/** AppearanceRow — превью, выбор файла и удаление для одного вида оформления. */
function AppearanceRow({
testId,
label,
hint,
fileId,
preview,
name,
seed,
shape,
disabled,
onPick,
onRemove,
}: AppearanceRowProps) {
const { t } = useTranslation();
const url = avatarUrl(fileId);
const src = preview ?? (url === null ? undefined : url);
return (
<div className="flex flex-wrap items-center gap-3" data-testid={testId}>
{shape === 'wide' ? (
<span
className="flex h-16 w-40 items-center justify-center overflow-hidden rounded-[var(--radius-md)] border border-border/60 bg-surface-2 text-xs text-fg-muted"
data-testid={`${testId}-preview`}
>
{src === undefined ? (
t('settings.servers.appearance.empty')
) : (
<img src={src} alt="" className="h-full w-full object-cover" />
)}
</span>
) : (
<span data-testid={`${testId}-preview`}>
<Avatar
name={name}
seed={seed}
fileId={fileId}
preview={preview}
size="lg"
shape="square"
/>
</span>
)}
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">{label}</span>
<span className="block text-xs text-fg-muted">{hint}</span>
</span>
<Button variant="ghost" disabled={disabled} onClick={onPick} data-testid={`${testId}-upload`}>
{t('settings.servers.appearance.upload')}
</Button>
<Button
variant="ghost"
disabled={disabled || src === undefined}
onClick={onRemove}
data-testid={`${testId}-remove`}
>
{t('settings.servers.appearance.remove')}
</Button>
</div>
);
}
@@ -17,6 +17,7 @@ import {
} from '@/lib/identity'; } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks'; import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session'; import { useSessionStore } from '@/stores/session';
import { GuildAppearanceSection } from '@/pages/settings/GuildAppearanceSection';
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection'; import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
import { GuildEmojisSection } from '@/pages/settings/GuildEmojisSection'; import { GuildEmojisSection } from '@/pages/settings/GuildEmojisSection';
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection'; import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
@@ -190,6 +191,7 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
return ( return (
<> <>
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} /> <GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
<GuildAppearanceSection key={`${guild.id}-appearance`} guild={guild} canEdit={canManage} />
<GuildMembersSection <GuildMembersSection
guildId={guild.id} guildId={guild.id}
members={members.data} members={members.data}
+206
View File
@@ -0,0 +1,206 @@
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();
});
});