2e038a1d3f
- миграция 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-снапшота
323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { act, screen, waitFor, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
|
|
import {
|
|
findRequest,
|
|
installFetch,
|
|
installGatewaySocket,
|
|
json,
|
|
makeReadySnapshot,
|
|
makeUser,
|
|
renderApp,
|
|
resetStores,
|
|
type FetchRoute,
|
|
} from './helpers';
|
|
import { makeVoiceState } from './voiceRoutes';
|
|
import {
|
|
setAudioContextFactory,
|
|
unlockInterfaceSounds,
|
|
type AudioContextLike,
|
|
} from '@/lib/interfaceSounds';
|
|
import { playEventSound } from '@/stores/soundboard';
|
|
import { useSessionStore } from '@/stores/session';
|
|
import { useVoiceStore } from '@/stores/voice';
|
|
|
|
/**
|
|
* Замечания пользователя: иконка должна меняться сразу, баннер сервера виден в
|
|
* шапке, есть кнопка приглашения, а звуки событий играют даже без своих файлов
|
|
* на сервере (AGENT.md 7.4, 7.13, 7.15).
|
|
*/
|
|
|
|
const user = makeUser();
|
|
|
|
const channels = [{ id: 'c-general', name: 'общий', type: 'text' as const, position: 0 }];
|
|
|
|
function guildSummary(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 'g-1',
|
|
name: 'Сервер',
|
|
owner_id: 'user-1',
|
|
is_main: true,
|
|
member_count: 1,
|
|
my_permissions: ['MANAGE_GUILD', 'CREATE_INVITES'],
|
|
my_role_ids: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function guildDetail(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
...guildSummary(overrides),
|
|
description: '',
|
|
roles: [],
|
|
channels,
|
|
};
|
|
}
|
|
|
|
/** Маршруты оболочки, сервера и приглашений. */
|
|
function routes(guild: Record<string, unknown> = guildDetail()): FetchRoute[] {
|
|
return [
|
|
{
|
|
match: '/api/v1/guilds/g-1/invites',
|
|
method: 'POST',
|
|
response: () =>
|
|
json({
|
|
invite: {
|
|
code: 'abc123',
|
|
guild_id: 'g-1',
|
|
max_uses: 0,
|
|
uses: 0,
|
|
max_age_seconds: 86400,
|
|
created_at: '2026-09-20T10:00:00Z',
|
|
url: 'https://gl.test/invite/abc123',
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
match: '/api/v1/guilds/g-1/appearance/icon',
|
|
method: 'POST',
|
|
response: () => json({ guild: guildDetail({ icon_file_id: '777', banner_file_id: '555' }) }),
|
|
},
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [guildSummary(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/channels', response: () => json({ channels }) },
|
|
{ match: '/api/v1/guilds/g-1', response: () => json({ guild }) },
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Рендерит приложение с 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,
|
|
owner_id: ownerId,
|
|
my_permissions: permissions,
|
|
channels,
|
|
voice_states: [makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' })],
|
|
// Оформление приходит в снапшоте шлюза — так же, как в ReadyGuild.
|
|
...(typeof guild.banner_file_id === 'string' ? { banner_file_id: guild.banner_file_id } : {}),
|
|
...(typeof guild.icon_file_id === 'string' ? { icon_file_id: guild.icon_file_id } : {}),
|
|
},
|
|
]);
|
|
const fetchMock = installFetch(routes(guild));
|
|
const gateway = installGatewaySocket(snapshot);
|
|
renderApp('/app/g-1/c-general');
|
|
await gateway.greet();
|
|
await screen.findByTestId('guild-rail');
|
|
return { fetchMock, gateway };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetStores();
|
|
});
|
|
|
|
describe('оформление сервера в шапке и рейке', () => {
|
|
it('показывает баннер в шапке и иконку в рейке', async () => {
|
|
await bootApp(guildDetail({ banner_file_id: '555', icon_file_id: '777' }));
|
|
|
|
const header = await screen.findByTestId('guild-header');
|
|
expect(header.getAttribute('style') ?? '').toContain('/files/555');
|
|
expect(within(header).getByTestId('guild-banner')).toBeInTheDocument();
|
|
|
|
const rail = screen.getByTestId('guild-rail');
|
|
expect(rail.querySelector('img')?.getAttribute('src')).toBe('/files/777');
|
|
});
|
|
|
|
it('обновляет иконку сразу после загрузки, без перезагрузки', async () => {
|
|
const { fetchMock } = await bootApp(guildDetail());
|
|
|
|
// Загружаем иконку через секцию оформления в настройках сервера.
|
|
const dialog = await openServerAppearance();
|
|
const section = within(dialog).getByTestId('guild-appearance');
|
|
await userEvent.upload(
|
|
within(section).getByTestId('appearance-icon-input'),
|
|
new File(['картинка'], 'icon.png', { type: 'image/png' }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(findRequest(fetchMock, { url: '/appearance/icon', method: 'POST' })).toBeDefined();
|
|
});
|
|
// Рейка берёт иконку из session-store: она обязана обновиться сразу.
|
|
await waitFor(() => {
|
|
const rail = screen.getByTestId('guild-rail');
|
|
expect(rail.querySelector('img')?.getAttribute('src')).toBe('/files/777');
|
|
});
|
|
expect(useSessionStore.getState().guilds[0]?.icon_file_id).toBe('777');
|
|
});
|
|
|
|
it('применяет оформление из GUILD_UPDATE другого клиента', async () => {
|
|
const { gateway } = await bootApp(guildDetail());
|
|
|
|
act(() => {
|
|
gateway.dispatch('GUILD_UPDATE', {
|
|
guild_id: 'g-1',
|
|
name: 'Сервер',
|
|
icon_file_id: '999',
|
|
banner_file_id: '888',
|
|
accent_color: 0x2ecc71,
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(useSessionStore.getState().guilds[0]?.icon_file_id).toBe('999');
|
|
});
|
|
expect(useSessionStore.getState().guilds[0]?.banner_file_id).toBe('888');
|
|
const header = screen.getByTestId('guild-header');
|
|
expect(header.getAttribute('style') ?? '').toContain('/files/888');
|
|
});
|
|
});
|
|
|
|
/** Открывает настройки сервера и ждёт секцию оформления. */
|
|
async function openServerAppearance(): Promise<HTMLElement> {
|
|
await userEvent.click(await screen.findByLabelText('Настройки сервера'));
|
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
|
await within(dialog).findByTestId('guild-appearance');
|
|
return dialog;
|
|
}
|
|
|
|
describe('приглашение из шапки сервера', () => {
|
|
it('создаёт ссылку и копирует её', async () => {
|
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
value: { writeText },
|
|
configurable: true,
|
|
});
|
|
const { fetchMock } = await bootApp();
|
|
|
|
await userEvent.click(await screen.findByTestId('guild-invite'));
|
|
const panel = await screen.findByTestId('guild-invite-panel');
|
|
const link = await within(panel).findByTestId('guild-invite-link');
|
|
|
|
expect(findRequest(fetchMock, { url: '/invites', method: 'POST' })).toBeDefined();
|
|
expect(link).toHaveValue('https://gl.test/invite/abc123');
|
|
|
|
await userEvent.click(within(panel).getByTestId('guild-invite-copy'));
|
|
await waitFor(() => {
|
|
expect(writeText).toHaveBeenCalledWith('https://gl.test/invite/abc123');
|
|
});
|
|
expect(await within(panel).findByText('Скопировано')).toBeInTheDocument();
|
|
});
|
|
|
|
it('не показывает кнопку без права приглашать', async () => {
|
|
await bootApp(guildDetail({ my_permissions: [], owner_id: 'user-9' }));
|
|
|
|
await screen.findByTestId('guild-header');
|
|
expect(screen.queryByTestId('guild-invite')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('встроенные звуки интерфейса', () => {
|
|
/** Простой счётчик осцилляторов: проверяем, что сигнал действительно играет. */
|
|
function fakeAudio(options: { suspended?: boolean } = {}): {
|
|
context: AudioContextLike;
|
|
started: number[];
|
|
resumed: () => number;
|
|
} {
|
|
const started: number[] = [];
|
|
let resumeCount = 0;
|
|
const context: AudioContextLike = {
|
|
currentTime: 0,
|
|
...(options.suspended === true
|
|
? {
|
|
state: 'suspended',
|
|
resume: () => {
|
|
resumeCount += 1;
|
|
return Promise.resolve();
|
|
},
|
|
}
|
|
: {}),
|
|
destination: {} as AudioNode,
|
|
createOscillator: () =>
|
|
({
|
|
type: 'sine',
|
|
frequency: { value: 0 },
|
|
connect: () => undefined,
|
|
start: (when: number) => started.push(when),
|
|
stop: () => undefined,
|
|
}) as unknown as OscillatorNode,
|
|
createGain: () =>
|
|
({
|
|
gain: {
|
|
setValueAtTime: () => undefined,
|
|
linearRampToValueAtTime: () => undefined,
|
|
},
|
|
connect: () => undefined,
|
|
}) as unknown as GainNode,
|
|
};
|
|
return { context, started, resumed: () => resumeCount };
|
|
}
|
|
|
|
it('играет сигнал события, когда на сервере нет своего звука', () => {
|
|
const { context, started } = fakeAudio();
|
|
setAudioContextFactory(() => context);
|
|
|
|
playEventSound('g-1', 'message');
|
|
|
|
expect(started).toHaveLength(1);
|
|
});
|
|
|
|
it('не играет, когда встроенные звуки выключены', () => {
|
|
const { context, started } = fakeAudio();
|
|
setAudioContextFactory(() => context);
|
|
useVoiceStore.setState({ interfaceSoundsEnabled: false });
|
|
|
|
playEventSound('g-1', 'message');
|
|
|
|
expect(started).toHaveLength(0);
|
|
});
|
|
|
|
it('молчит при выключенном звуке', () => {
|
|
const { context, started } = fakeAudio();
|
|
setAudioContextFactory(() => context);
|
|
useVoiceStore.setState({ headphonesEnabled: false });
|
|
|
|
playEventSound('g-1', 'message');
|
|
|
|
expect(started).toHaveLength(0);
|
|
});
|
|
|
|
it('уважает звук сервера из палитры', () => {
|
|
const { context, started } = fakeAudio();
|
|
setAudioContextFactory(() => context);
|
|
useSessionStore.setState({
|
|
guilds: [
|
|
{
|
|
...guildSummary(),
|
|
channels,
|
|
emojis: [],
|
|
sounds: [{ id: 's-1', name: 'ping', file_id: '42', kind: 'ui', event: 'message' }],
|
|
},
|
|
],
|
|
});
|
|
|
|
playEventSound('g-1', 'message');
|
|
|
|
// Встроенный сигнал не играет: приоритет у звука сервера.
|
|
expect(started).toHaveLength(0);
|
|
});
|
|
|
|
it('будит звук первым жестом: до него браузер молчит', () => {
|
|
const { context, resumed } = fakeAudio({ suspended: true });
|
|
setAudioContextFactory(() => context);
|
|
unlockInterfaceSounds();
|
|
|
|
window.dispatchEvent(new Event('pointerdown'));
|
|
|
|
// Контекст создан уже внутри жеста и сразу разбужен.
|
|
expect(resumed()).toBe(1);
|
|
});
|
|
});
|