a48b865bc9
Решение владельца 27.09.2026 (D-084): сервис ставится на сервер конкретного человека, и ему всё равно нужен свой OAuth у провайдера — поддержка общего входа только создаёт трение (регистрация приложений, redirect URI, модерация, чужие ключи в конфиге). Способы входа остаются: пароль + 2FA и ключи доступа (WebAuthn). Удалено: - сервер: internal/auth/oauth.go, internal/server/oauth.go, internal/store/oauth.go и их тесты; поля и методы конфига OAuth*; oauthLimiter и регистрация ручек; отображение ошибок oauth.*; features.oauth_enabled/oauth_providers в /meta; - клиент: web/src/api/oauth.ts, раздел «Вход через внешние сервисы», кнопки провайдеров на странице входа, ключи i18n (ru/en), тесты и фикстуры; - установщик: переменные OAUTH_* из .env, .env.example и шаблона (хелпер чтения существующих значений переименован в existing_value — он остался нужен для VAPID_SUBJECT); - зависимость golang.org/x/oauth2 (go mod tidy). Схема: миграция 00027 удаляет таблицу oauth_accounts (00019 не переписываем — она применена на стендах). Откат миграции возвращает структуру; тест TestOAuthRemovalMigration проверяет накат, откат и повторный накат. AGENT.md (локальный) помечает пункты про OAuth как отменённые.
569 lines
21 KiB
TypeScript
569 lines
21 KiB
TypeScript
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,
|
|
VideoQuality: fake.VideoQuality,
|
|
};
|
|
});
|
|
|
|
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';
|
|
import { useVoiceStore } from '@/stores/voice';
|
|
|
|
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,
|
|
unfurl_enabled: false,
|
|
passkeys_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('показывает пинг участника в подсказке индикатора связи', { timeout: 20_000 }, async () => {
|
|
await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
|
|
|
|
await clickChannel('Голосовая');
|
|
await waitConnected();
|
|
|
|
// Свой пинг приходит из отчёта WebRTC (candidate-pair) и публикуется
|
|
// в комнату; у чужого участника он приходит по data-каналу.
|
|
const room = FakeRoom.latest();
|
|
room.setStats([
|
|
['pair', { type: 'candidate-pair', state: 'succeeded', currentRoundTripTime: 0.042 }],
|
|
]);
|
|
// Первый такт может прийтись на момент, когда микрофон ещё не опубликован,
|
|
// поэтому ждём следующего (интервал публикации пинга — секунда). Пинг виден
|
|
// только при наведении: он попадает в подсказку индикатора связи.
|
|
const ownQuality = () =>
|
|
within(screen.getByTestId('voice-tile-user-1')).getByTestId('voice-quality');
|
|
await waitFor(
|
|
() => {
|
|
expect(ownQuality()).toHaveAttribute(
|
|
'title',
|
|
expect.stringContaining('Отличное соединение, 42 мс'),
|
|
);
|
|
},
|
|
{ timeout: 5_000 },
|
|
);
|
|
expect(ownQuality()).toHaveAttribute('data-latency', '42');
|
|
expect(ownQuality()).toHaveAttribute(
|
|
'aria-label',
|
|
expect.stringContaining('задержка до голосового сервера'),
|
|
);
|
|
|
|
// Второй участник подключается к комнате и присылает свой пинг.
|
|
const remote = room.addRemote('user-2', 'Bob');
|
|
act(() => {
|
|
room.emitData(
|
|
new TextEncoder().encode(JSON.stringify({ ms: 128 })),
|
|
remote,
|
|
'glchat.latency',
|
|
);
|
|
});
|
|
const remoteTile = await screen.findByTestId('voice-tile-user-2');
|
|
await waitFor(
|
|
() => {
|
|
expect(within(remoteTile).getByTestId('voice-quality')).toHaveAttribute(
|
|
'title',
|
|
expect.stringContaining('Отличное соединение, 128 мс'),
|
|
);
|
|
},
|
|
{ timeout: 5_000 },
|
|
);
|
|
|
|
// Свой пинг уходит в комнату по служебной теме и без гарантии доставки.
|
|
await waitFor(
|
|
() => {
|
|
expect(room.localParticipant.data.length).toBeGreaterThan(0);
|
|
},
|
|
{ timeout: 5_000 },
|
|
);
|
|
const sent = room.localParticipant.data.at(-1);
|
|
expect(sent?.options).toMatchObject({ topic: 'glchat.latency', reliable: false });
|
|
expect(new TextDecoder().decode(sent?.payload ?? new Uint8Array())).toContain('"ms"');
|
|
});
|
|
|
|
it('выключенный пинг участников не опрашивает SDK', async () => {
|
|
useVoiceStore.setState({ latencySharingEnabled: false });
|
|
await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
|
|
|
|
await clickChannel('Голосовая');
|
|
await waitConnected();
|
|
|
|
const track = FakeRoom.latest().localParticipant.getTrackPublication('microphone')?.track;
|
|
const before = track?.statsRequests ?? 0;
|
|
await new Promise((resolve) => setTimeout(resolve, 1_500));
|
|
expect(track?.statsRequests).toBe(before);
|
|
expect(useVoiceConnectionStore.getState().latencies).toEqual({});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|