fix(install): публичный адрес LiveKit вычисляется после TLS_ENABLED

Установщик падал на 'TLS_ENABLED: unbound variable': расчёт LIVEKIT_PUBLIC_URL
стоял выше определения TLS_ENABLED. Значение перенесено после блока TLS,
dry-run проходит.
This commit is contained in:
2026-09-20 18:13:04 +03:00
parent ecd52c11d3
commit ca34d05377
33 changed files with 2505 additions and 71 deletions
+1
View File
@@ -90,6 +90,7 @@ const guild: SessionGuild = {
{ id: 'c-3', name: 'Голосовая', type: 'voice', position: 2 },
{ id: 'c-4', name: 'скрытая', type: 'text', position: 3, can_view: false },
],
emojis: [],
};
const authors: AuthorDirectory = {
+554
View File
@@ -0,0 +1,554 @@
import { createRef } from 'react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClientProvider } from '@tanstack/react-query';
import type { Message } from '@/api/messages';
import { parseGuildEmojisUpdate } from '@/api/gatewayEvents';
import { parseGuildEmojiList } from '@/api/emojis';
import type { Channel } from '@/api/types';
import { Composer } from '@/components/chat/Composer';
import { ComposerMirror } from '@/components/chat/ComposerMirror';
import { EmojiPicker } from '@/components/chat/EmojiPicker';
import { MessageContent } from '@/components/chat/MessageContent';
import { MessageList } from '@/components/chat/MessageList';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { customEmojiShortcode, parseCustomEmoji } from '@/lib/customEmoji';
import { parseInline } from '@/lib/markdown';
import { dispatchGatewayEvent } from '@/stores/gateway';
import { useSessionStore, type SessionGuild } from '@/stores/session';
import {
createTestQueryClient,
findRequest,
installFetch,
installGatewaySocket,
json,
makeMessage,
makeReadySnapshot,
makeUser,
messagesRoutes,
renderApp,
resetStores,
type FetchRoute,
} from './helpers';
/**
* Кастомные эмодзи сервера (AGENT.md 7.12): токен `<:имя:file_id>` в разметке,
* лента и зеркало рисуют картинку, пикер и автокомплит `:` вставляют токен,
* реакции ходят тем же токеном, а набор эмодзи живёт в снапшоте и обновляется
* событием `GUILD_EMOJIS_UPDATE`.
*/
const pepe = { id: 'e-1', name: 'pepe', file_id: '900', animated: false, token: '<:pepe:900>' };
const parrot = {
id: 'e-2',
name: 'party_parrot',
file_id: '901',
animated: true,
token: '<a:party_parrot:901>',
};
/** Сервер снапшота с кастомными эмодзи. */
function guildWithEmojis(emojis: SessionGuild['emojis'] = [pepe, parrot]): SessionGuild {
return {
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
member_count: 1,
my_role_ids: [],
my_permissions: [],
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
emojis,
};
}
const authors: AuthorDirectory = {
resolve: () => ({
id: 'user-2',
name: 'Bob',
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: true,
}),
resolveMention: (userId) => ({ name: userId, isMe: false }),
resolveMemberName: (userId) => `user ${userId}`,
loaded: true,
};
const textChannel: Channel = { id: 'c-1', name: 'общий', type: 'text', position: 0 };
beforeEach(() => {
resetStores();
});
afterEach(() => {
resetStores();
});
describe('кастомные эмодзи: разбор токена', () => {
it('распознаёт <:имя:id> и <a:имя:id>', () => {
const tokens = parseInline('привет <:pepe:900> и <a:party_parrot:901>');
expect(tokens.map((token) => token.kind)).toEqual(['custom-emoji', 'custom-emoji']);
expect(tokens.map((token) => token.raw)).toEqual(['<:pepe:900>', '<a:party_parrot:901>']);
expect(parseCustomEmoji('<:pepe:900>')).toEqual({
name: 'pepe',
fileId: '900',
animated: false,
});
expect(parseCustomEmoji('<a:party_parrot:901>')).toEqual({
name: 'party_parrot',
fileId: '901',
animated: true,
});
expect(customEmojiShortcode('pepe')).toBe(':pepe:');
});
it('битый токен остаётся текстом и не ломает разметку', () => {
const content = '**жирный** <:pepe:> <:p:1> <:pepe:abc> <:имя:1>';
expect(parseInline(content)).toHaveLength(1);
expect(parseInline(content)[0]?.kind).toBe('bold');
expect(parseCustomEmoji('<:pepe:>')).toBeNull();
expect(parseCustomEmoji('<:pepe:abc>')).toBeNull();
expect(parseCustomEmoji('<:p:1>')).toBeNull();
});
it('не конфликтует с упоминаниями и ссылками на комнаты', () => {
const kinds = parseInline('<@42> <#7> <:pepe:900>').map((token) => token.kind);
expect(kinds).toEqual(['mention', 'channel-link', 'custom-emoji']);
});
it('разбирает список эмодзи из ответа сервера', () => {
const parsed = parseGuildEmojiList([
{ id: 'e-1', name: 'pepe', file_id: '900', animated: false, token: '<:pepe:900>' },
// Токен не пришёл — собираем сами по тому же формату.
{ id: 'e-2', name: 'kek', file_id: '902', animated: true },
// Битая запись отбрасывается.
{ id: 'e-3', name: 'no_file' },
]);
expect(parsed.map((emoji) => emoji.token)).toEqual(['<:pepe:900>', '<a:kek:902>']);
});
});
describe('кастомные эмодзи: лента сообщений', () => {
it('рисует картинку, если эмодзи есть в снапшоте сервера', () => {
useSessionStore.setState({ guilds: [guildWithEmojis()] });
const { container } = render(<MessageContent content={'привет <:pepe:900>!'} />);
const image = container.querySelector('img');
expect(image).toHaveAttribute('src', '/files/900');
expect(image).toHaveAttribute('alt', ':pepe:');
expect(image).toHaveAttribute('title', ':pepe:');
expect(image).toHaveAttribute('loading', 'lazy');
expect(image).toHaveClass('h-5', 'w-5');
expect(container.textContent).toBe('привет !');
});
it('неизвестный файл показывается текстом :имя:, разметка остаётся целой', () => {
useSessionStore.setState({ guilds: [guildWithEmojis([])] });
const { container } = render(
<MessageContent content={'**жирный** и <:pepe:900> с <a:party_parrot:901>'} />,
);
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('strong')).toHaveTextContent('жирный');
expect(container.textContent).toBe('жирный и :pepe: с :party_parrot:');
});
it('без снапшота сервера (личная беседа) токен остаётся текстом', () => {
const { container } = render(<MessageContent content="<:pepe:900>" />);
expect(container.querySelector('img')).toBeNull();
expect(container.textContent).toBe(':pepe:');
});
});
describe('кастомные эмодзи: зеркало композера', () => {
it('вне каретки рисует картинку, внутри токена — исходный текст', () => {
useSessionStore.setState({ guilds: [guildWithEmojis()] });
const mirrorRef = createRef<HTMLDivElement>();
const value = 'эмодзи <:pepe:900>';
const { rerender } = render(
<ComposerMirror value={value} selection={{ start: 0, end: 0 }} mirrorRef={mirrorRef} />,
);
const image = mirrorRef.current?.querySelector('img');
expect(image).toHaveAttribute('src', '/files/900');
expect(image).toHaveClass('h-5', 'w-5', 'align-[-0.15em]');
// Каретка внутри токена — показываем маркеры (весь токен) для правки.
rerender(
<ComposerMirror value={value} selection={{ start: 10, end: 10 }} mirrorRef={mirrorRef} />,
);
expect(mirrorRef.current?.querySelector('img')).toBeNull();
expect(mirrorRef.current?.textContent).toBe(value);
});
it('неизвестный эмодзи показывается текстом :имя:', () => {
useSessionStore.setState({ guilds: [guildWithEmojis([])] });
const mirrorRef = createRef<HTMLDivElement>();
render(
<ComposerMirror
value="<:pepe:900>"
selection={{ start: 0, end: 0 }}
mirrorRef={mirrorRef}
/>,
);
expect(mirrorRef.current?.querySelector('img')).toBeNull();
expect(mirrorRef.current?.textContent).toBe(':pepe:');
});
});
describe('кастомные эмодзи: пикер', () => {
it('показывает эмодзи сервера, ищет по имени и отдаёт токен', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
const picked: string[] = [];
render(
<EmojiPicker open guildId="g-1" onClose={() => undefined} onPick={(emoji) => picked.push(emoji)} />,
);
const section = screen.getByTestId('emoji-picker-server');
expect(within(section).getByText(':pepe:')).toBeVisible();
expect(within(section).getByText(':party_parrot:')).toBeVisible();
expect(section.querySelector('img[src="/files/900"]')).not.toBeNull();
expect(within(section).getByText('анимированный')).toBeVisible();
await visitor.type(screen.getByLabelText('Поиск эмодзи сервера'), 'party');
expect(within(section).queryByText(':pepe:')).toBeNull();
await visitor.click(within(section).getByRole('button', { name: 'Выбрать эмодзи party_parrot' }));
expect(picked).toEqual(['<a:party_parrot:901>']);
});
it('без сервера секция эмодзи сервера не показывается', () => {
useSessionStore.setState({ guilds: [guildWithEmojis()] });
render(
<EmojiPicker open guildId={null} onClose={() => undefined} onPick={() => undefined} />,
);
expect(screen.queryByTestId('emoji-picker-server')).toBeNull();
});
});
describe('кастомные эмодзи: пикер в композере', () => {
it('вставляет токен по клику в позицию каретки', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
installFetch([
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
{ match: '/api/v1/channels/c-1/typing', method: 'POST', response: () => json({ ok: true }) },
]);
render(
<QueryClientProvider client={createTestQueryClient()}>
<Composer
channel={textChannel}
guildId="g-1"
authors={authors}
replyTo={null}
onCancelReply={() => undefined}
disabledReason={null}
canAttach
/>
</QueryClientProvider>,
);
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.click(screen.getByRole('button', { name: 'Эмодзи' }));
await visitor.click(screen.getByRole('button', { name: 'Выбрать эмодзи pepe' }));
expect(input).toHaveValue('<:pepe:900>');
// Зеркало сразу показывает картинку — предпросмотр совпадает с лентой.
await waitFor(() => {
expect(screen.getByTestId('composer-mirror').querySelector('img')).toHaveAttribute(
'src',
'/files/900',
);
});
});
});
describe('кастомные эмодзи: автокомплит (:)', () => {
function renderComposer(guildId: string | null = 'g-1') {
const fetchMock = installFetch([
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/channels/c-1/typing', method: 'POST', response: () => json({ ok: true }) },
]);
const view = render(
<QueryClientProvider client={createTestQueryClient()}>
<Composer
channel={textChannel}
guildId={guildId}
authors={authors}
replyTo={null}
onCancelReply={() => undefined}
disabledReason={null}
canAttach
/>
</QueryClientProvider>,
);
return { ...view, fetchMock };
}
it('дополняет список эмодзи сервера и вставляет токен', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
renderComposer();
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.type(input, ':pep');
const list = await screen.findByTestId('composer-autocomplete');
expect(within(list).getByText(':pepe:')).toBeVisible();
expect(list.querySelector('img[src="/files/900"]')).not.toBeNull();
await visitor.keyboard('{Enter}');
expect(input).toHaveValue('<:pepe:900>');
});
it('точное совпадение по имени выше юникод-словаря', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({
guilds: [
guildWithEmojis([
{ id: 'e-3', name: 'fire', file_id: '950', animated: false, token: '<:fire:950>' },
]),
],
});
renderComposer();
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.type(input, ':fire');
const options = await screen.findAllByTestId('composer-option-emoji');
expect(options[0]).toHaveTextContent(':fire:');
await visitor.keyboard('{Enter}');
expect(input).toHaveValue('<:fire:950>');
});
it('в личной беседе кастомных эмодзи в подсказке нет', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
renderComposer(null);
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.type(input, ':pep');
// Юникод-словарь по `pep` пуст, кастомных эмодзи без сервера нет.
await waitFor(() => {
expect(screen.queryByTestId('composer-autocomplete')).toBeNull();
});
});
});
describe('кастомные эмодзи: реакции', () => {
const user = makeUser();
const members = [
{
user_id: 'user-1',
username: 'alice',
display_name: 'Alice',
nickname: 'Аля',
status: 'online' as const,
is_instance_admin: false,
joined_at: '2026-09-01T10:00:00Z',
role_ids: [],
},
];
/** Приложение на маршруте комнаты сервера с кастомными эмодзи в READY. */
async function boot(messages: Message[], extraRoutes: FetchRoute[] = []) {
const snapshot = makeReadySnapshot([
{
id: 'g-1',
name: 'Сервер',
is_main: true,
my_permissions: ['SEND_MESSAGES', 'ADD_REACTIONS'],
channels: [{ id: 'c-1', name: 'общий' }],
emojis: [pepe, parrot],
},
]);
const fetchMock = installFetch([
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ 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/meta', response: () => json({ max_upload_size: 26_214_400 }) },
...messagesRoutes('c-1', messages, extraRoutes),
]);
const gateway = installGatewaySocket(snapshot);
const view = renderApp('/app/g-1/c-1');
await screen.findByTestId('guild-rail');
await gateway.greet();
return { ...view, fetchMock, gateway };
}
it('рисует чип реакции картинкой и ходит тем же токеном', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot(
[
makeMessage({
id: 'm-1',
channel_id: 'c-1',
author_id: 'user-2',
content: 'реагируй',
reactions: [{ emoji: '<:pepe:900>', count: 1, me: false, user_ids: ['user-2'] }],
}),
],
[
{
match: '/api/v1/channels/c-1/messages/m-1/reactions/',
method: 'PUT',
response: () => json({ ok: true }),
},
],
);
const chip = await screen.findByRole('button', { name: 'Реакция :pepe:: 1' });
expect(chip.querySelector('img[src="/files/900"]')).not.toBeNull();
await visitor.click(chip);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Реакция :pepe:: 2' })).toHaveAttribute(
'aria-pressed',
'true',
);
});
// Токен уезжает в пути закодированным (%3C%3Apepe%3A900%3E).
const request = findRequest(fetchMock, { url: '/reactions/', method: 'PUT' });
expect(request?.url).toContain(encodeURIComponent('<:pepe:900>'));
});
it('ставит реакцию кастомным эмодзи из пикера', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot(
[makeMessage({ id: 'm-1', channel_id: 'c-1', author_id: 'user-2', content: 'меню' })],
[
{
match: '/api/v1/channels/c-1/messages/m-1/reactions/',
method: 'PUT',
response: () => json({ ok: true }),
},
],
);
await screen.findByText('меню');
const article = screen.getByTestId('message-m-1');
await visitor.click(within(article).getByRole('button', { name: 'Действия с сообщением' }));
await visitor.click(within(article).getByRole('menuitem', { name: 'Реакция' }));
await visitor.click(
within(screen.getByTestId('emoji-picker-server')).getByRole('button', {
name: 'Выбрать эмодзи party_parrot',
}),
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Реакция :party_parrot:: 1' })).toBeVisible();
});
expect(findRequest(fetchMock, { url: '/reactions/', method: 'PUT' })?.url).toContain(
encodeURIComponent('<a:party_parrot:901>'),
);
});
});
describe('кастомные эмодзи: снапшот и событие шлюза', () => {
it('читает эмодзи из READY и обновляет их по GUILD_EMOJIS_UPDATE', () => {
const snapshot = makeReadySnapshot([
{ id: 'g-1', name: 'Сервер', emojis: [pepe] },
]);
dispatchGatewayEvent({ op: 0, t: 'READY', s: 1, d: snapshot });
expect(useSessionStore.getState().guilds[0]?.emojis).toEqual([pepe]);
dispatchGatewayEvent({
op: 0,
t: 'GUILD_EMOJIS_UPDATE',
s: 2,
d: { guild_id: 'g-1', emojis: [pepe, parrot] },
});
expect(useSessionStore.getState().guilds[0]?.emojis).toEqual([pepe, parrot]);
});
it('разбирает payload события и отбрасывает битые записи', () => {
const parsed = parseGuildEmojisUpdate({
guild_id: 'g-1',
emojis: [{ id: 'e-1', name: 'pepe', file_id: '900', animated: false }],
});
expect(parsed?.guild_id).toBe('g-1');
expect(parsed?.emojis[0]?.token).toBe('<:pepe:900>');
expect(parseGuildEmojisUpdate({ emojis: [] })).toBeNull();
});
it('без события эмодзи сервера остаются пустыми', () => {
dispatchGatewayEvent({
op: 0,
t: 'READY',
s: 1,
d: makeReadySnapshot([{ id: 'g-1', name: 'Сервер' }]),
});
expect(useSessionStore.getState().guilds[0]?.emojis).toEqual([]);
});
});
describe('кастомные эмодзи: беседа без сервера', () => {
it('в личной беседе пикер реакций не показывает эмодзи сервера', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
render(
<MessageList
channelId="dm-1"
channelName="Боб"
direct
messages={[
makeMessage({ id: 'm-1', channel_id: 'dm-1', author_id: 'user-2', content: 'привет' }),
]}
status="ready"
error={null}
hasMore={false}
loadingMore={false}
unreadAnchorId={null}
highlightId={null}
authors={authors}
currentUserId="user-1"
canManageMessages={false}
canSend
onReload={() => undefined}
onLoadMore={() => undefined}
onReply={() => undefined}
/>,
);
expect(screen.getByText('привет')).toBeVisible();
// Сервер комнаты неизвестен, значит кастомных эмодзи в пикере нет.
const article = screen.getByTestId('message-m-1');
await visitor.click(within(article).getByRole('button', { name: 'Действия с сообщением' }));
await visitor.click(within(article).getByRole('menuitem', { name: 'Реакция' }));
expect(screen.queryByTestId('emoji-picker-server')).toBeNull();
expect(screen.getByRole('dialog', { name: 'Выбор эмодзи' })).toBeVisible();
});
});
+336
View File
@@ -0,0 +1,336 @@
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,
json,
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<typeof installFetch>): 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 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();
});
});
});
+9
View File
@@ -203,6 +203,8 @@ interface SnapshotGuildInput {
icon_file_id?: string;
my_permissions?: string[];
my_nickname?: string;
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
emojis?: { id: string; name: string; file_id: string; animated?: boolean; token?: string }[];
channels?: {
id: string;
name: string;
@@ -260,6 +262,13 @@ export function makeReadySnapshot(
member_ids: ['user-1'],
my_role_ids: [],
my_permissions: guild.my_permissions ?? [],
emojis: (guild.emojis ?? []).map((emoji) => ({
id: emoji.id,
name: emoji.name,
file_id: emoji.file_id,
animated: emoji.animated ?? false,
token: emoji.token ?? `<:${emoji.name}:${emoji.file_id}>`,
})),
})),
dm_channels: dmChannels.map((channel) => ({
id: channel.id,
+1
View File
@@ -137,6 +137,7 @@ describe('разметка: упоминания каналов', () => {
my_role_ids: [],
my_permissions: [],
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
emojis: [],
};
it('подсвечивает канал из снапшота и не трогает неизвестный', () => {
+222
View File
@@ -0,0 +1,222 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import type { Message } from '@/api/messages';
import { MessageList } from '@/components/chat/MessageList';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { parseSystemMessage, systemMessageUserId } from '@/lib/systemMessage';
import { makeMessage, makeUser, json, installFetch, installGatewaySocket, messagesRoutes, renderApp, resetStores } from './helpers';
/**
* Системные сообщения (AGENT.md §7.6): сервер пишет их с `type: "system"`,
* а в `content` — JSON о событии. Лента показывает человекочитаемую строку
* с именем участника, а неизвестное событие и мусорный JSON — как есть.
*/
const authors: AuthorDirectory = {
resolve: (userId) => ({
id: userId ?? '',
name: userId === 'user-2' ? 'Боб' : 'неизвестный',
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: userId === 'user-2',
}),
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Боб' : userId, isMe: false }),
resolveMemberName: (userId) => (userId === 'user-2' ? 'Боб' : null),
loaded: true,
};
function systemMessage(content: string, id = 'm-1'): Message {
return makeMessage({
id,
channel_id: 'c-1',
content,
type: 'system',
});
}
function renderList(messages: Message[]) {
return render(
<MessageList
channelId="c-1"
channelName="общий"
messages={messages}
status="ready"
error={null}
hasMore={false}
loadingMore={false}
unreadAnchorId={null}
highlightId={null}
authors={authors}
currentUserId="user-1"
canManageMessages
canSend
onReload={() => undefined}
onLoadMore={() => undefined}
onReply={() => undefined}
/>,
);
}
beforeEach(() => {
resetStores();
});
describe('системные сообщения: разбор payload', () => {
it('читает события участника и комнаты', () => {
expect(parseSystemMessage('{"event":"member_join","user_id":"123"}')).toEqual({
kind: 'member_join',
userId: '123',
});
expect(parseSystemMessage('{"event":"member_kick","user_id":"123"}')).toEqual({
kind: 'member_kick',
userId: '123',
});
expect(parseSystemMessage('{"event":"channel_created","name":"общий","type":"text"}')).toEqual({
kind: 'channel_created',
name: 'общий',
channelType: 'text',
});
expect(systemMessageUserId('{"event":"member_kick","user_id":"123"}')).toBe('123');
expect(systemMessageUserId('{"event":"channel_created","name":"общий"}')).toBeNull();
});
it('не падает на мусоре и неизвестном событии', () => {
expect(parseSystemMessage('Аля вошла')).toBeNull();
expect(parseSystemMessage('{"event":"guild_boost"}')).toBeNull();
expect(parseSystemMessage('{"event":"member_join"}')).toBeNull();
expect(parseSystemMessage('"строка"')).toBeNull();
expect(parseSystemMessage('null')).toBeNull();
});
});
describe('системные сообщения: лента', () => {
it('member_join — «{имя} присоединился к серверу»', () => {
renderList([systemMessage('{"event":"member_join","user_id":"user-2"}')]);
const body = screen.getByTestId('message-m-1');
expect(body).toHaveTextContent('Боб присоединился к серверу');
// Сырой JSON в ленте не показываем.
expect(body.textContent).not.toContain('member_join');
});
it('member_kick — «{имя} покинул сервер (исключён)»', () => {
renderList([systemMessage('{"event":"member_kick","user_id":"user-2"}')]);
expect(screen.getByTestId('message-m-1')).toHaveTextContent(
'Боб покинул сервер (исключён)',
);
});
it('channel_created — комната и голосовая комната', () => {
renderList([
systemMessage('{"event":"channel_created","name":"общий","type":"text"}', 'm-1'),
systemMessage('{"event":"channel_created","name":"Голосовая","type":"voice"}', 'm-2'),
]);
expect(screen.getByTestId('message-m-1')).toHaveTextContent('Комната #общий создана');
expect(screen.getByTestId('message-m-2')).toHaveTextContent(
'Голосовая комната Голосовая создана',
);
});
it('неизвестное событие и невалидный JSON показываются как есть', () => {
renderList([
systemMessage('{"event":"guild_boost","level":2}', 'm-1'),
systemMessage('Аля вошла', 'm-2'),
]);
expect(screen.getByTestId('message-m-1')).toHaveTextContent('{"event":"guild_boost","level":2}');
expect(screen.getByTestId('message-m-2')).toHaveTextContent('Аля вошла');
});
it('неизвестный участник подписан «участник»', () => {
renderList([systemMessage('{"event":"member_join","user_id":"user-9"}')]);
expect(screen.getByTestId('message-m-1')).toHaveTextContent('участник присоединился к серверу');
});
it('остаётся центрированной строкой без аватара и действий', () => {
renderList([systemMessage('{"event":"member_join","user_id":"user-2"}')]);
const body = screen.getByTestId('message-m-1');
expect(body).toHaveClass('items-center', 'text-center');
expect(body).toHaveAttribute('data-message-type', 'system');
expect(within(body).queryByRole('button')).toBeNull();
expect(screen.getByTestId('message-group').querySelector('.inline-flex.shrink-0')).toBeNull();
});
});
describe('системные сообщения: имя через справочник авторов', () => {
it('добирает имя участника по GET /users/{id}', async () => {
const user = makeUser();
const snapshot = {
user: {
id: 'user-1',
username: 'alice',
display_name: 'Alice',
is_instance_admin: false,
badges: [],
},
guilds: [
{
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
roles: [],
member_ids: ['user-1'],
my_role_ids: [],
my_permissions: ['SEND_MESSAGES'],
emojis: [],
},
],
dm_channels: [],
read_states: [],
session_id: 'session-1',
heartbeat_interval_ms: 45_000,
};
installFetch([
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
// Участника нет в списке сервера — имя приходит из GET /users/{id}.
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{
match: '/api/v1/users/user-9',
response: () =>
json({
user: {
id: 'user-9',
username: 'carol',
display_name: 'Кэрол',
is_instance_admin: false,
badges: [],
},
}),
},
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
...messagesRoutes('c-1', [
makeMessage({
id: 'm-1',
channel_id: 'c-1',
type: 'system',
content: '{"event":"member_join","user_id":"user-9"}',
}),
]),
]);
const gateway = installGatewaySocket(snapshot);
renderApp('/app/g-1/c-1');
await screen.findByTestId('guild-rail');
await gateway.greet();
await waitFor(() => {
expect(screen.getByTestId('message-m-1')).toHaveTextContent('Кэрол присоединился к серверу');
});
});
});