feat(webhooks): вебхуки комнат — бэкенд, клиент и настройки комнаты
- миграция 00012: таблица webhooks, снимок имени и аватара в messages (AGENT.md 7.11)
- API: список/создание/правка/удаление и пересоздание токена (MANAGE_WEBHOOKS,
step-up при создании, аудит), загрузка аватара отдельной multipart-ручкой
- исполнение POST /webhooks/{id}/{token} без сессии: content, username,
avatar_url, файлы создателя вебхука, лимит 30/мин на вебхук
- клиент: пункт настроек «Комната» со списком вебхуков, копированием ссылки,
пересозданием токена и удалением
- сообщения вебхуков: имя, аватар и значок в ленте вместо «неизвестного автора»
- fix: неполный ответ REST больше не затирает данные READY-снапшота
This commit is contained in:
@@ -103,6 +103,14 @@ const authors: AuthorDirectory = {
|
||||
isInstanceAdmin: false,
|
||||
isMember: true,
|
||||
}),
|
||||
resolveWebhook: () => ({
|
||||
id: '',
|
||||
name: 'вебхук',
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isMember: false,
|
||||
}),
|
||||
resolveMention: (userId) => ({
|
||||
name: members.find((member) => member.user_id === userId)?.display_name ?? userId,
|
||||
isMe: false,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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,
|
||||
findRequest,
|
||||
installFetch,
|
||||
json,
|
||||
makeReadySnapshot,
|
||||
makeUser,
|
||||
renderApp,
|
||||
resetStores,
|
||||
installGatewaySocket,
|
||||
messagesRoutes,
|
||||
type FetchRoute,
|
||||
} from './helpers';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
|
||||
/**
|
||||
* Вебхуки комнаты (AGENT.md 7.11): секция «Комната» в настройках — список,
|
||||
* создание со step-up, пересоздание ссылки, копирование URL и удаление.
|
||||
*/
|
||||
|
||||
const user = makeUser({ is_instance_admin: true });
|
||||
|
||||
const channel = { id: 'c-1', name: 'общий', type: 'text' as const, position: 0 };
|
||||
|
||||
function webhook(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'w-1',
|
||||
guild_id: 'g-1',
|
||||
channel_id: 'c-1',
|
||||
name: 'Деплой',
|
||||
token: 'token-1',
|
||||
created_by: 'user-1',
|
||||
created_at: '2026-09-20T10:00:00Z',
|
||||
url: 'https://gl.test/api/v1/webhooks/w-1/token-1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function routes(overrides: { list?: unknown[] } = {}): FetchRoute[] {
|
||||
return [
|
||||
{
|
||||
match: '/api/v1/users/@me/guilds',
|
||||
response: () =>
|
||||
json({
|
||||
guilds: [
|
||||
{
|
||||
id: 'g-1',
|
||||
name: 'Сервер',
|
||||
owner_id: 'user-1',
|
||||
is_main: true,
|
||||
member_count: 1,
|
||||
my_permissions: ['MANAGE_WEBHOOKS', 'ADMINISTRATOR'],
|
||||
my_role_ids: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [channel] }) },
|
||||
{ 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: {
|
||||
id: 'g-1',
|
||||
name: 'Сервер',
|
||||
owner_id: 'user-1',
|
||||
is_main: true,
|
||||
member_count: 1,
|
||||
my_role_ids: [],
|
||||
my_permissions: ['MANAGE_WEBHOOKS', 'ADMINISTRATOR'],
|
||||
channels: [channel],
|
||||
roles: [],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/channels/c-1/webhooks',
|
||||
response: () => json({ webhooks: overrides.list ?? [webhook()] }),
|
||||
},
|
||||
...messagesRoutes('c-1', []),
|
||||
];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
});
|
||||
|
||||
/** Открывает настройки комнаты с готовым списком вебхуков. */
|
||||
async function openChannelSettings(list?: unknown[]) {
|
||||
const fetchMock = installFetch(routes(list === undefined ? {} : { list }));
|
||||
const snapshot = makeReadySnapshot([
|
||||
{
|
||||
id: 'g-1',
|
||||
name: 'Сервер',
|
||||
is_main: true,
|
||||
my_permissions: ['MANAGE_WEBHOOKS', 'ADMINISTRATOR'],
|
||||
channels: [channel],
|
||||
},
|
||||
]);
|
||||
const gateway = installGatewaySocket(snapshot);
|
||||
renderApp('/app/g-1/c-1');
|
||||
await gateway.greet();
|
||||
await screen.findByTestId('message-list');
|
||||
await userEvent.click(await screen.findByTestId('channel-settings'));
|
||||
const content = await screen.findByTestId('settings-content');
|
||||
await within(content).findByTestId('channel-webhooks');
|
||||
return { fetchMock, content };
|
||||
}
|
||||
|
||||
describe('вебхуки комнаты', () => {
|
||||
it('показывает список со ссылкой и копирует её', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
const { content } = await openChannelSettings();
|
||||
|
||||
const item = within(content).getByTestId('webhook-w-1');
|
||||
expect(within(item).getByText('Деплой')).toBeVisible();
|
||||
expect(within(item).getByTestId('webhook-url-w-1')).toHaveValue(
|
||||
'https://gl.test/api/v1/webhooks/w-1/token-1',
|
||||
);
|
||||
|
||||
await userEvent.click(within(item).getByRole('button', { name: 'Скопировать' }));
|
||||
expect(writeText).toHaveBeenCalledWith('https://gl.test/api/v1/webhooks/w-1/token-1');
|
||||
expect(await within(item).findByText('Скопировано')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('создаёт вебхук и подтверждает личность по запросу сервера', async () => {
|
||||
const { fetchMock, content } = await openChannelSettings([]);
|
||||
// Первый раз сервер требует step-up: секция показывает форму подтверждения.
|
||||
fetchMock.mockImplementationOnce(() =>
|
||||
Promise.resolve(json({ error: { code: 'auth.step_up_required', message: 'need' } }, 403)),
|
||||
);
|
||||
fetchMock.mockImplementationOnce(() =>
|
||||
Promise.resolve(json({ error: { code: 'auth.step_up_required', message: 'need' } }, 403)),
|
||||
);
|
||||
|
||||
await userEvent.click(within(content).getByTestId('webhook-create-open'));
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Создать вебхук' });
|
||||
await userEvent.type(within(dialog).getByLabelText('Имя вебхука'), 'Новый');
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Создать' }));
|
||||
|
||||
const stepUpField = await within(dialog).findByLabelText('Ваш пароль');
|
||||
await userEvent.type(stepUpField, 'correct-horse-battery');
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Подтвердить' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findRequest(fetchMock, { url: '/auth/step-up', method: 'POST' })).toBeDefined();
|
||||
});
|
||||
await waitFor(() => {
|
||||
const created = findRequest(fetchMock, { url: '/channels/c-1/webhooks', method: 'POST' });
|
||||
expect(bodyOf(created)).toMatchObject({ name: 'Новый' });
|
||||
});
|
||||
});
|
||||
|
||||
it('пересоздаёт ссылку и удаляет вебхук', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
const { fetchMock, content } = await openChannelSettings();
|
||||
|
||||
const item = within(content).getByTestId('webhook-w-1');
|
||||
await userEvent.click(within(item).getByTestId('webhook-rotate-w-1'));
|
||||
await waitFor(() => {
|
||||
expect(findRequest(fetchMock, { url: '/webhooks/w-1', method: 'PATCH' })?.body).toMatchObject(
|
||||
{ regenerate_token: true },
|
||||
);
|
||||
});
|
||||
|
||||
await userEvent.click(within(item).getByTestId('webhook-delete-w-1'));
|
||||
await waitFor(() => {
|
||||
expect(findRequest(fetchMock, { url: '/webhooks/w-1', method: 'DELETE' })).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('не показывает секцию без права MANAGE_WEBHOOKS', async () => {
|
||||
const fetchMock = installFetch(routes({ list: [{ ...webhook(), id: 'w-2' }] }));
|
||||
const snapshot = makeReadySnapshot([
|
||||
{
|
||||
id: 'g-1',
|
||||
name: 'Сервер',
|
||||
is_main: true,
|
||||
// Права есть, но владелец — другой пользователь и админа нет.
|
||||
owner_id: 'user-9',
|
||||
my_permissions: [],
|
||||
channels: [channel],
|
||||
},
|
||||
]);
|
||||
const gateway = installGatewaySocket(snapshot);
|
||||
renderApp('/app/g-1/c-1');
|
||||
await gateway.greet();
|
||||
await screen.findByTestId('message-list');
|
||||
|
||||
// Кнопки настроек комнаты нет вовсе: секция недоступна из интерфейса.
|
||||
expect(screen.queryByTestId('channel-settings')).toBeNull();
|
||||
expect(useSessionStore.getState().guilds[0]?.my_permissions).toEqual([]);
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('открывает настройки комнаты из шапки чата', async () => {
|
||||
await openChannelSettings();
|
||||
|
||||
expect(useUiStore.getState().settingsOpen).toBe(true);
|
||||
expect(useUiStore.getState().settingsSection).toBe('channel');
|
||||
expect(useSessionStore.getState().settingsChannelId).toBe('c-1');
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,14 @@ const authors: AuthorDirectory = {
|
||||
isInstanceAdmin: false,
|
||||
isMember: true,
|
||||
}),
|
||||
resolveWebhook: () => ({
|
||||
id: '',
|
||||
name: 'вебхук',
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isMember: false,
|
||||
}),
|
||||
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Bob' : userId, isMe: false }),
|
||||
loaded: true,
|
||||
};
|
||||
|
||||
@@ -74,6 +74,14 @@ const authors: AuthorDirectory = {
|
||||
isInstanceAdmin: false,
|
||||
isMember: true,
|
||||
}),
|
||||
resolveWebhook: () => ({
|
||||
id: '',
|
||||
name: 'вебхук',
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isMember: false,
|
||||
}),
|
||||
resolveMention: (userId) => ({ name: userId, isMe: false }),
|
||||
resolveMemberName: (userId) => `user ${userId}`,
|
||||
loaded: true,
|
||||
|
||||
@@ -89,14 +89,22 @@ function routes(guild: Record<string, unknown> = guildDetail()): FetchRoute[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** Рендерит приложение с READY-снапшотом сервера. */
|
||||
/**
|
||||
* Рендерит приложение с READY-снапшотом сервера. Права и владелец берутся из
|
||||
* того же описания сервера, что и ответ REST: снапшот — источник правды.
|
||||
*/
|
||||
async function bootApp(guild: Record<string, unknown> = guildDetail()) {
|
||||
const permissions = Array.isArray(guild.my_permissions)
|
||||
? (guild.my_permissions as string[])
|
||||
: ['MANAGE_GUILD', 'CREATE_INVITES'];
|
||||
const ownerId = typeof guild.owner_id === 'string' ? guild.owner_id : 'user-1';
|
||||
const snapshot = makeReadySnapshot([
|
||||
{
|
||||
id: 'g-1',
|
||||
name: 'Сервер',
|
||||
is_main: true,
|
||||
my_permissions: ['MANAGE_GUILD', 'CREATE_INVITES'],
|
||||
owner_id: ownerId,
|
||||
my_permissions: permissions,
|
||||
channels,
|
||||
voice_states: [makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' })],
|
||||
// Оформление приходит в снапшоте шлюза — так же, как в ReadyGuild.
|
||||
|
||||
@@ -21,10 +21,25 @@ const authors: AuthorDirectory = {
|
||||
isInstanceAdmin: false,
|
||||
isMember: true,
|
||||
}),
|
||||
resolveWebhook: () => ({
|
||||
id: '',
|
||||
name: 'вебхук',
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isMember: false,
|
||||
}),
|
||||
resolveMention: (userId) => ({ name: userId === 'user-1' ? 'Аля' : 'Bob', isMe: false }),
|
||||
loaded: true,
|
||||
};
|
||||
|
||||
/** Сообщение без автора-пользователя: так приходят сообщения вебхуков. */
|
||||
function authorlessMessage(overrides: Partial<Message> & { id: string }): Message {
|
||||
const base = message(overrides);
|
||||
delete base.author_id;
|
||||
return base;
|
||||
}
|
||||
|
||||
function message(overrides: Partial<Message> & { id: string }): Message {
|
||||
return {
|
||||
channel_id: 'c-1',
|
||||
@@ -196,3 +211,59 @@ describe('виды сообщений: действия', () => {
|
||||
expect(within(groups[1] as HTMLElement).getByText('Bob')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('сообщения вебхуков', () => {
|
||||
it('показывает имя и аватар вебхука, а не «неизвестного автора»', () => {
|
||||
const webhookAuthors: AuthorDirectory = {
|
||||
...authors,
|
||||
resolveWebhook: (message) => ({
|
||||
id: message.webhook_id ?? '',
|
||||
name: message.webhook_name ?? 'вебхук',
|
||||
avatarFileId: undefined,
|
||||
avatarUrl: message.webhook_avatar,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isMember: false,
|
||||
}),
|
||||
};
|
||||
render(
|
||||
<MessageList
|
||||
channelId="c-1"
|
||||
channelName="общий"
|
||||
messages={[
|
||||
authorlessMessage({
|
||||
id: 'w-1',
|
||||
type: 'webhook',
|
||||
content: 'сборка прошла',
|
||||
webhook_id: 'hook-1',
|
||||
webhook_name: 'Деплой',
|
||||
webhook_avatar: '/files/777',
|
||||
}),
|
||||
]}
|
||||
status="ready"
|
||||
error={null}
|
||||
hasMore={false}
|
||||
loadingMore={false}
|
||||
unreadAnchorId={null}
|
||||
highlightId={null}
|
||||
authors={webhookAuthors}
|
||||
currentUserId="user-1"
|
||||
canManageMessages
|
||||
canSend
|
||||
onReload={() => undefined}
|
||||
onLoadMore={() => undefined}
|
||||
onReply={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const body = article('w-1');
|
||||
expect(body).toHaveAttribute('data-message-type', 'webhook');
|
||||
// Имя, значок и аватар рисует шапка группы (само сообщение — только текст).
|
||||
const group = screen.getByTestId('message-group');
|
||||
expect(group).toHaveAttribute('data-message-type', 'webhook');
|
||||
expect(within(group).getByText('Деплой')).toBeVisible();
|
||||
expect(within(group).getByTestId('webhook-badge')).toHaveTextContent('вебхук');
|
||||
expect(group.querySelector('img')?.getAttribute('src')).toBe('/files/777');
|
||||
expect(within(group).queryByText('неизвестный')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { act } from '@testing-library/react';
|
||||
|
||||
import { parseGatewaySnapshot } from '@/api/gateway';
|
||||
import type { GuildSummary } from '@/api/types';
|
||||
import { dispatchGatewayEvent, useGatewayStore } from '@/stores/gateway';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { makeReadySnapshot } from './helpers';
|
||||
@@ -63,6 +64,46 @@ describe('session store', () => {
|
||||
expect(useGatewayStore.getState().heartbeatIntervalMs).toBe(45_000);
|
||||
});
|
||||
|
||||
it('не затирает данные снапшота неполной сводкой REST', () => {
|
||||
act(() => {
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'READY',
|
||||
s: 1,
|
||||
d: makeReadySnapshot([
|
||||
{
|
||||
id: 'g-1',
|
||||
name: 'Main',
|
||||
owner_id: 'user-9',
|
||||
is_main: true,
|
||||
my_permissions: ['MANAGE_WEBHOOKS'],
|
||||
channels: [{ id: 'c-1', name: 'general', position: 0 }],
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
// Ответ REST пришёл без части полей: undefined не должен перетирать снапшот.
|
||||
act(() => {
|
||||
useSessionStore.getState().upsertGuild({
|
||||
id: 'g-1',
|
||||
name: 'Main из REST',
|
||||
owner_id: undefined,
|
||||
is_main: undefined,
|
||||
member_count: undefined,
|
||||
my_role_ids: undefined,
|
||||
my_permissions: undefined,
|
||||
} as unknown as GuildSummary);
|
||||
});
|
||||
|
||||
const guild = useSessionStore.getState().guilds[0];
|
||||
expect(guild?.name).toBe('Main из REST');
|
||||
expect(guild?.owner_id).toBe('user-9');
|
||||
expect(guild?.is_main).toBe(true);
|
||||
expect(guild?.my_permissions).toEqual(['MANAGE_WEBHOOKS']);
|
||||
expect(guild?.channels.map((channel) => channel.id)).toEqual(['c-1']);
|
||||
});
|
||||
|
||||
it('keeps channels with can_send=false (only can_view=false is hidden)', () => {
|
||||
act(() => {
|
||||
dispatchGatewayEvent({
|
||||
|
||||
@@ -31,6 +31,14 @@ const authors: AuthorDirectory = {
|
||||
isInstanceAdmin: false,
|
||||
isMember: userId === 'user-2',
|
||||
}),
|
||||
resolveWebhook: () => ({
|
||||
id: '',
|
||||
name: 'вебхук',
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isMember: false,
|
||||
}),
|
||||
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Боб' : userId, isMe: false }),
|
||||
resolveMemberName: (userId) => (userId === 'user-2' ? 'Боб' : null),
|
||||
loaded: true,
|
||||
|
||||
Reference in New Issue
Block a user