feat(voice): журнал обработанных вебхуков LiveKit
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('livekit-client', async () => {
|
||||
const fake = await import('./fakeLiveKit');
|
||||
return {
|
||||
Room: fake.FakeRoom,
|
||||
RoomEvent: fake.RoomEvent,
|
||||
ConnectionQuality: fake.ConnectionQuality,
|
||||
Track: fake.Track,
|
||||
};
|
||||
});
|
||||
|
||||
import { FakeRoom } from './fakeLiveKit';
|
||||
import {
|
||||
installFetch,
|
||||
installGatewaySocket,
|
||||
json,
|
||||
makeReadySnapshot,
|
||||
makeUser,
|
||||
recordedRequests,
|
||||
renderApp,
|
||||
type FetchRoute,
|
||||
type RecordedRequest,
|
||||
} from './helpers';
|
||||
import { makeVoiceState, voiceRoutes, type VoiceRoutesOptions } from './voiceRoutes';
|
||||
import type { InstanceMeta } from '@/api/meta';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
|
||||
const ALL_PERMISSIONS = [
|
||||
'VIEW_GUILD',
|
||||
'SEND_MESSAGES',
|
||||
'CONNECT_VOICE',
|
||||
'MUTE_MEMBERS',
|
||||
'DEAFEN_MEMBERS',
|
||||
'MOVE_MEMBERS',
|
||||
];
|
||||
|
||||
const members = [
|
||||
{
|
||||
user_id: 'user-1',
|
||||
username: 'alice',
|
||||
display_name: 'Alice',
|
||||
status: 'online',
|
||||
is_instance_admin: false,
|
||||
joined_at: '2026-09-19T09:00:00Z',
|
||||
role_ids: [],
|
||||
},
|
||||
{
|
||||
user_id: 'user-2',
|
||||
username: 'bob',
|
||||
display_name: 'Bob',
|
||||
status: 'online',
|
||||
is_instance_admin: false,
|
||||
joined_at: '2026-09-19T09:00:00Z',
|
||||
role_ids: [],
|
||||
},
|
||||
];
|
||||
|
||||
const enabledMeta: InstanceMeta = {
|
||||
name: 'glchat',
|
||||
version: 'v0.1.0-test',
|
||||
commit: 'deadbee',
|
||||
build_date: '2026-09-19T00:00:00Z',
|
||||
api_version: 'v1',
|
||||
base_url: 'https://gl.localhost',
|
||||
files_url: 'https://files.gl.localhost',
|
||||
gateway_url: 'wss://gl.localhost/gateway',
|
||||
rtc_path: '/rtc',
|
||||
max_upload_size: 26_214_400,
|
||||
features: {
|
||||
registration_enabled: true,
|
||||
anti_bot_enabled: false,
|
||||
voice_enabled: true,
|
||||
web_push_enabled: false,
|
||||
passkeys_enabled: false,
|
||||
oauth_enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
interface BootOptions extends VoiceRoutesOptions {
|
||||
path?: string;
|
||||
permissions?: string[];
|
||||
/** Участники, которые уже сидят в комнатах (снапшот READY). */
|
||||
seated?: Parameters<typeof makeVoiceState>[0][];
|
||||
meta?: InstanceMeta;
|
||||
}
|
||||
|
||||
/** Поднимает приложение, играет рукопожатие шлюза и мокает голосовые ручки. */
|
||||
async function bootVoice(options: BootOptions = {}) {
|
||||
const snapshot = makeReadySnapshot([
|
||||
{
|
||||
id: 'g-1',
|
||||
name: 'Сервер',
|
||||
is_main: true,
|
||||
my_permissions: options.permissions ?? ALL_PERMISSIONS,
|
||||
channels: [
|
||||
{ id: 'c-general', name: 'общий', position: 0 },
|
||||
{ id: 'c-voice', name: 'Голосовая', type: 'voice', position: 1 },
|
||||
{ id: 'c-other', name: 'Вторая', type: 'voice', position: 2 },
|
||||
],
|
||||
voice_states: options.seated ?? [],
|
||||
},
|
||||
]);
|
||||
|
||||
const voice = voiceRoutes({
|
||||
channelIds: ['c-voice', 'c-other'],
|
||||
...options,
|
||||
// READY и REST должны рассказывать про один и тот же состав комнат.
|
||||
states: (options.states ?? options.seated ?? []).map((state) => makeVoiceState(state)),
|
||||
});
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) },
|
||||
{ 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(options.meta ?? enabledMeta) },
|
||||
...voice.routes,
|
||||
] as FetchRoute[]);
|
||||
|
||||
const gateway = installGatewaySocket(snapshot);
|
||||
renderApp(options.path ?? '/app/g-1/c-general');
|
||||
await screen.findByTestId('guild-rail');
|
||||
await gateway.greet();
|
||||
return { ...voice, fetchMock, gateway };
|
||||
}
|
||||
|
||||
/** Последний запрос по фильтру: так проверяются PATCH и leave. */
|
||||
function lastRequest(
|
||||
fetchMock: ReturnType<typeof installFetch>,
|
||||
predicate: { url: string; method?: string },
|
||||
): RecordedRequest | undefined {
|
||||
return recordedRequests(fetchMock)
|
||||
.filter(
|
||||
(request) =>
|
||||
request.url.includes(predicate.url) &&
|
||||
(predicate.method === undefined || request.method === predicate.method.toUpperCase()),
|
||||
)
|
||||
.at(-1);
|
||||
}
|
||||
|
||||
/** Клик по голосовой комнате в сайдбаре: вход и переход на её страницу. */
|
||||
async function clickChannel(name: string): Promise<void> {
|
||||
await userEvent.click(await screen.findByLabelText(`Открыть комнату ${name}`));
|
||||
}
|
||||
|
||||
async function waitConnected(): Promise<void> {
|
||||
await waitFor(() => {
|
||||
expect(useVoiceConnectionStore.getState().status).toBe('connected');
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeRoom.reset();
|
||||
});
|
||||
|
||||
describe('голосовая комната: вход', () => {
|
||||
it('входит по клику на канал и рисует участников', async () => {
|
||||
const voice = await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
|
||||
|
||||
await clickChannel('Голосовая');
|
||||
await waitConnected();
|
||||
|
||||
// Сервер записал состояние, комната SDK создана.
|
||||
expect(voice.joinCount()).toBe(1);
|
||||
expect(FakeRoom.latest().connectCalls).toHaveLength(1);
|
||||
expect(await screen.findByTestId('voice-tile-user-2')).toBeVisible();
|
||||
expect(screen.getByTestId('voice-tile-user-1')).toBeVisible();
|
||||
// Список участников под голосовой комнатой в сайдбаре.
|
||||
const list = screen.getByLabelText('Участники комнаты «Голосовая»');
|
||||
expect(within(list).getByLabelText('Участник Bob')).toBeVisible();
|
||||
});
|
||||
|
||||
it('подключается кнопкой в панели комнаты', async () => {
|
||||
await bootVoice({ path: '/app/g-1/c-voice' });
|
||||
|
||||
await userEvent.click(await screen.findByTestId('voice-join'));
|
||||
|
||||
await waitConnected();
|
||||
expect(FakeRoom.latest().connectCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('при выключенном голосе показывает подсказку вместо подключения', async () => {
|
||||
const voice = await bootVoice({
|
||||
meta: { ...enabledMeta, features: { ...enabledMeta.features, voice_enabled: false } },
|
||||
});
|
||||
|
||||
await clickChannel('Голосовая');
|
||||
|
||||
expect(await screen.findByTestId('voice-disabled')).toHaveTextContent(
|
||||
'Голос не настроен на инстансе',
|
||||
);
|
||||
expect(screen.queryByTestId('voice-join')).toBeNull();
|
||||
expect(voice.joinCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('переходит в другую комнату через выход из старой', async () => {
|
||||
const voice = await bootVoice();
|
||||
|
||||
await clickChannel('Голосовая');
|
||||
await waitConnected();
|
||||
await clickChannel('Вторая');
|
||||
await waitFor(() => {
|
||||
expect(useVoiceConnectionStore.getState().channelId).toBe('c-other');
|
||||
});
|
||||
|
||||
const requests = recordedRequests(voice.fetchMock)
|
||||
.filter((request) => request.url.includes('/voice/'))
|
||||
.map((request) => `${request.method} ${request.url.split('/api/v1')[1] ?? ''}`);
|
||||
expect(requests).toEqual([
|
||||
'POST /channels/c-voice/voice/join',
|
||||
'POST /channels/c-voice/voice/leave',
|
||||
'POST /channels/c-other/voice/join',
|
||||
]);
|
||||
});
|
||||
|
||||
it('показывает ошибку «в комнате нет свободных мест»', async () => {
|
||||
await bootVoice({ joinError: { code: 'voice.channel_full', status: 403 } });
|
||||
|
||||
await clickChannel('Голосовая');
|
||||
|
||||
// Ошибку показывают и сайдбар под комнатой, и панель комнаты.
|
||||
expect((await screen.findAllByText('В комнате нет свободных мест.')).length).toBeGreaterThan(0);
|
||||
expect(useVoiceConnectionStore.getState().status).toBe('error');
|
||||
});
|
||||
|
||||
it('показывает ошибку нехватки прав', async () => {
|
||||
await bootVoice({ joinError: { code: 'perm.denied', status: 403 } });
|
||||
|
||||
await clickChannel('Голосовая');
|
||||
|
||||
expect(
|
||||
(await screen.findAllByText('Недостаточно прав для этого действия.')).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('голосовая комната: панель пользователя', () => {
|
||||
it('кнопки микрофона, камеры и наушников управляют SDK и уходят на сервер', async () => {
|
||||
const { fetchMock } = await bootVoice();
|
||||
await clickChannel('Голосовая');
|
||||
await waitConnected();
|
||||
|
||||
const panel = screen.getByLabelText('Панель пользователя');
|
||||
await userEvent.click(within(panel).getByLabelText('Выключить микрофон'));
|
||||
|
||||
expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
|
||||
method: 'setMicrophoneEnabled',
|
||||
value: false,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
|
||||
self_mute: true,
|
||||
});
|
||||
});
|
||||
expect(within(panel).getByLabelText('Включить микрофон')).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false',
|
||||
);
|
||||
|
||||
await userEvent.click(within(panel).getByLabelText('Включить камеру'));
|
||||
expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
|
||||
method: 'setCameraEnabled',
|
||||
value: true,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
|
||||
camera: true,
|
||||
});
|
||||
});
|
||||
|
||||
await userEvent.click(within(panel).getByLabelText('Выключить звук'));
|
||||
await waitFor(() => {
|
||||
expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
|
||||
self_deaf: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('серверный мьют блокирует кнопку микрофона с подсказкой', async () => {
|
||||
const { gateway } = await bootVoice();
|
||||
await clickChannel('Голосовая');
|
||||
await waitConnected();
|
||||
|
||||
act(() => {
|
||||
gateway.dispatch(
|
||||
'VOICE_STATE_UPDATE',
|
||||
makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_mute: true }),
|
||||
);
|
||||
});
|
||||
|
||||
const panel = screen.getByLabelText('Панель пользователя');
|
||||
const mic = await within(panel).findByLabelText('Микрофон выключен модератором');
|
||||
expect(mic).toBeDisabled();
|
||||
expect(mic).toHaveAttribute('title', 'Микрофон выключен модератором');
|
||||
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('серверное глушение блокирует кнопку наушников с подсказкой', async () => {
|
||||
const { gateway } = await bootVoice();
|
||||
await clickChannel('Голосовая');
|
||||
await waitConnected();
|
||||
|
||||
act(() => {
|
||||
gateway.dispatch(
|
||||
'VOICE_STATE_UPDATE',
|
||||
makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_deaf: true }),
|
||||
);
|
||||
});
|
||||
|
||||
const panel = screen.getByLabelText('Панель пользователя');
|
||||
const sound = await within(panel).findByLabelText('Звук выключен модератором');
|
||||
expect(sound).toBeDisabled();
|
||||
});
|
||||
|
||||
it('смена устройства применяется к сессии', async () => {
|
||||
await bootVoice();
|
||||
await clickChannel('Голосовая');
|
||||
await waitConnected();
|
||||
|
||||
await act(async () => {
|
||||
await useVoiceConnectionStore.getState().switchActiveDevice('audioinput', 'mic-2');
|
||||
});
|
||||
|
||||
expect(FakeRoom.latest().deviceSwitches).toEqual([{ kind: 'audioinput', deviceId: 'mic-2' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('голосовая комната: события шлюза', () => {
|
||||
it('VOICE_STATE_UPDATE добавляет и убирает участника без перезагрузки', async () => {
|
||||
const { gateway } = await bootVoice();
|
||||
|
||||
expect(screen.queryByLabelText('Участник Bob')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
gateway.dispatch(
|
||||
'VOICE_STATE_UPDATE',
|
||||
makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' }),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByLabelText('Участник Bob')).toBeVisible();
|
||||
|
||||
act(() => {
|
||||
gateway.dispatch('VOICE_STATE_UPDATE', {
|
||||
guild_id: 'g-1',
|
||||
channel_id: 'c-voice',
|
||||
user_id: 'user-2',
|
||||
left: true,
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Участник Bob')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('показывает индикацию речи участника', async () => {
|
||||
await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
|
||||
await clickChannel('Голосовая');
|
||||
await waitConnected();
|
||||
|
||||
act(() => {
|
||||
FakeRoom.latest().addRemote('user-2', 'Bob');
|
||||
FakeRoom.latest().activeSpeakers(['user-2']);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('voice-tile-user-2')).toHaveAttribute('data-speaking', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
it('показывает и скрывает подсказку о речи при выключенном микрофоне', async () => {
|
||||
await bootVoice();
|
||||
await clickChannel('Голосовая');
|
||||
await waitConnected();
|
||||
|
||||
await act(async () => {
|
||||
await useVoiceConnectionStore.getState().setMicEnabled(false);
|
||||
});
|
||||
act(() => {
|
||||
FakeRoom.latest().activeSpeakers(['user-1']);
|
||||
});
|
||||
|
||||
const notice = await screen.findByTestId('voice-speaking-notice');
|
||||
expect(notice).toHaveTextContent('Вы говорите с выключенным микрофоном');
|
||||
|
||||
await userEvent.click(within(notice).getByLabelText('Скрыть'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('voice-speaking-notice')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('голосовая комната: выход', () => {
|
||||
it('отключается по кнопке и очищает состояние', async () => {
|
||||
const { fetchMock } = await bootVoice({
|
||||
seated: [{ user_id: 'user-2', channel_id: 'c-voice' }],
|
||||
});
|
||||
await clickChannel('Голосовая');
|
||||
await waitConnected();
|
||||
|
||||
const tile = screen.getByTestId('voice-tile-user-1');
|
||||
await userEvent.click(within(tile).getByLabelText('Отключиться'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useVoiceConnectionStore.getState().status).toBe('idle');
|
||||
});
|
||||
expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
|
||||
expect(screen.queryByTestId('voice-tile-user-1')).toBeNull();
|
||||
// Своя строка исчезла из сайдбара, а оставшийся участник — на месте.
|
||||
expect(screen.queryByLabelText('Участник Alice')).toBeNull();
|
||||
expect(screen.getAllByLabelText('Участник Bob').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('отключается по двойному клику на комнату в сайдбаре', async () => {
|
||||
const { fetchMock } = await bootVoice();
|
||||
await clickChannel('Голосовая');
|
||||
await waitConnected();
|
||||
|
||||
await userEvent.dblClick(await screen.findByLabelText('Открыть комнату Голосовая'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useVoiceConnectionStore.getState().status).toBe('idle');
|
||||
});
|
||||
expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('голосовая комната: модерация', () => {
|
||||
it('серверный мьют участника из меню', async () => {
|
||||
const voice = await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
|
||||
await clickChannel('Голосовая');
|
||||
await waitConnected();
|
||||
|
||||
await userEvent.click(
|
||||
within(await screen.findByTestId('voice-tile-user-2')).getByLabelText('Участник Bob'),
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name: 'Выключить микрофон' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(voice.states.find((state) => state.user_id === 'user-2')?.server_mute).toBe(true);
|
||||
});
|
||||
expect(
|
||||
lastRequest(voice.fetchMock, { url: '/voice-states/user-2', method: 'PATCH' })?.body,
|
||||
).toEqual({ server_mute: true });
|
||||
});
|
||||
|
||||
it('перемещение участника в другую комнату из меню', async () => {
|
||||
const voice = await bootVoice({
|
||||
channelIds: ['c-voice', 'c-other'],
|
||||
seated: [{ user_id: 'user-2', channel_id: 'c-voice' }],
|
||||
});
|
||||
await clickChannel('Голосовая');
|
||||
await waitConnected();
|
||||
|
||||
await userEvent.click(
|
||||
within(await screen.findByTestId('voice-tile-user-2')).getByLabelText('Участник Bob'),
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name: 'Переместить в «Вторая»' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(voice.states.find((state) => state.user_id === 'user-2')?.channel_id).toBe('c-other');
|
||||
});
|
||||
});
|
||||
|
||||
it('перемещает участника перетаскиванием на голосовую комнату', async () => {
|
||||
const voice = await bootVoice({
|
||||
channelIds: ['c-voice', 'c-other'],
|
||||
seated: [{ user_id: 'user-2', channel_id: 'c-voice' }],
|
||||
});
|
||||
const target = await screen.findByLabelText('Открыть комнату Вторая');
|
||||
const dataTransfer = {
|
||||
types: ['application/x-glchat-voice-user'],
|
||||
getData: () => 'user-2',
|
||||
setData: vi.fn(),
|
||||
dropEffect: 'none',
|
||||
};
|
||||
|
||||
fireEvent.dragOver(target, { dataTransfer });
|
||||
fireEvent.drop(target, { dataTransfer });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(voice.states.find((state) => state.user_id === 'user-2')?.channel_id).toBe('c-other');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user