feat(voice): журнал обработанных вебхуков LiveKit
This commit is contained in:
@@ -206,7 +206,7 @@ describe('чат комнаты: лента', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('оставляет голосовую комнату с заглушкой', async () => {
|
||||
it('открывает панель голосовой комнаты вместо чата', async () => {
|
||||
await boot({
|
||||
path: '/app/g-1/c-voice',
|
||||
channels: [
|
||||
@@ -215,7 +215,9 @@ describe('чат комнаты: лента', () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Голосовая комната')).toBeVisible();
|
||||
// Вместо ленты сообщений — панель голоса с кнопкой подключения.
|
||||
expect(await screen.findByTestId('voice-join')).toBeVisible();
|
||||
expect(screen.getByText('В комнате пока никого нет')).toBeVisible();
|
||||
expect(screen.queryByTestId('message-list')).toBeNull();
|
||||
expect(screen.queryByTestId('composer')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Подставной `livekit-client` для тестов: комната, участники и события под
|
||||
* управлением теста. В jsdom настоящий SDK не работает (нет WebRTC), поэтому
|
||||
* `vi.mock('livekit-client', () => import('./fakeLiveKit'))` подменяет его
|
||||
* целиком, а логика `lib/livekit.ts` и стора проверяется настоящая.
|
||||
*/
|
||||
|
||||
export const RoomEvent = {
|
||||
ParticipantConnected: 'participantConnected',
|
||||
ParticipantDisconnected: 'participantDisconnected',
|
||||
TrackSubscribed: 'trackSubscribed',
|
||||
TrackUnsubscribed: 'trackUnsubscribed',
|
||||
TrackPublished: 'trackPublished',
|
||||
TrackUnpublished: 'trackUnpublished',
|
||||
TrackMuted: 'trackMuted',
|
||||
TrackUnmuted: 'trackUnmuted',
|
||||
LocalTrackPublished: 'localTrackPublished',
|
||||
LocalTrackUnpublished: 'localTrackUnpublished',
|
||||
ConnectionQualityChanged: 'connectionQualityChanged',
|
||||
ActiveSpeakersChanged: 'activeSpeakersChanged',
|
||||
Reconnecting: 'reconnecting',
|
||||
Reconnected: 'reconnected',
|
||||
Disconnected: 'disconnected',
|
||||
} as const;
|
||||
|
||||
export const ConnectionQuality = {
|
||||
Excellent: 'excellent',
|
||||
Good: 'good',
|
||||
Poor: 'poor',
|
||||
Lost: 'lost',
|
||||
Unknown: 'unknown',
|
||||
} as const;
|
||||
|
||||
export const Track = {
|
||||
Source: {
|
||||
Camera: 'camera',
|
||||
Microphone: 'microphone',
|
||||
ScreenShare: 'screen_share',
|
||||
ScreenShareAudio: 'screen_share_audio',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Дорожка: помнит, к каким элементам её подключили. */
|
||||
export class FakeTrack {
|
||||
readonly kind: 'audio' | 'video';
|
||||
readonly attached: HTMLMediaElement[] = [];
|
||||
|
||||
constructor(kind: 'audio' | 'video') {
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
attach(element: HTMLMediaElement): HTMLMediaElement {
|
||||
this.attached.push(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
detach(element: HTMLMediaElement): HTMLMediaElement[] {
|
||||
const index = this.attached.indexOf(element);
|
||||
if (index >= 0) {
|
||||
this.attached.splice(index, 1);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export class FakePublication {
|
||||
constructor(
|
||||
readonly source: string,
|
||||
readonly track: FakeTrack,
|
||||
public isMuted: boolean,
|
||||
) {}
|
||||
}
|
||||
|
||||
type Handler = (...args: never[]) => void;
|
||||
|
||||
/** Участник комнаты: публикации, флаги и записанные вызовы SDK. */
|
||||
export class FakeParticipant {
|
||||
isSpeaking = false;
|
||||
connectionQuality: string = ConnectionQuality.Excellent;
|
||||
identity: string;
|
||||
name: string;
|
||||
readonly isLocal: boolean;
|
||||
/** Вызовы, которые проверяют тесты: `setMicrophoneEnabled(false)` и т.п. */
|
||||
readonly calls: { method: string; value: unknown }[] = [];
|
||||
private readonly publications = new Map<string, FakePublication>();
|
||||
|
||||
constructor(
|
||||
private readonly room: FakeRoom,
|
||||
identity: string,
|
||||
name: string,
|
||||
isLocal = false,
|
||||
) {
|
||||
this.identity = identity;
|
||||
this.name = name;
|
||||
this.isLocal = isLocal;
|
||||
}
|
||||
|
||||
get isMicrophoneEnabled(): boolean {
|
||||
return this.isLive(Track.Source.Microphone);
|
||||
}
|
||||
|
||||
get isCameraEnabled(): boolean {
|
||||
return this.isLive(Track.Source.Camera);
|
||||
}
|
||||
|
||||
get isScreenShareEnabled(): boolean {
|
||||
return this.isLive(Track.Source.ScreenShare);
|
||||
}
|
||||
|
||||
getTrackPublication(source: string): FakePublication | undefined {
|
||||
return this.publications.get(source);
|
||||
}
|
||||
|
||||
/** Публикация дорожки: используется и для своих, и для чужих участников. */
|
||||
setPublication(source: string, muted = false): FakePublication {
|
||||
const publication = new FakePublication(
|
||||
source,
|
||||
new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video'),
|
||||
muted,
|
||||
);
|
||||
this.publications.set(source, publication);
|
||||
return publication;
|
||||
}
|
||||
|
||||
removePublication(source: string): void {
|
||||
this.publications.delete(source);
|
||||
this.room.emit(RoomEvent.TrackUnsubscribed);
|
||||
}
|
||||
|
||||
setSpeaking(speaking: boolean): void {
|
||||
this.isSpeaking = speaking;
|
||||
}
|
||||
|
||||
private isLive(source: string): boolean {
|
||||
const publication = this.publications.get(source);
|
||||
return publication !== undefined && !publication.isMuted;
|
||||
}
|
||||
|
||||
private setLocal(source: string, enabled: boolean, method: string): void {
|
||||
this.calls.push({ method, value: enabled });
|
||||
const existing = this.publications.get(source);
|
||||
const track =
|
||||
existing?.track ?? new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video');
|
||||
this.publications.set(source, new FakePublication(source, track, !enabled));
|
||||
this.room.emit(RoomEvent.LocalTrackPublished);
|
||||
}
|
||||
|
||||
setMicrophoneEnabled(enabled: boolean): Promise<void> {
|
||||
this.setLocal(Track.Source.Microphone, enabled, 'setMicrophoneEnabled');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
setCameraEnabled(enabled: boolean): Promise<void> {
|
||||
this.setLocal(Track.Source.Camera, enabled, 'setCameraEnabled');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
setScreenShareEnabled(enabled: boolean): Promise<void> {
|
||||
this.setLocal(Track.Source.ScreenShare, enabled, 'setScreenShareEnabled');
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export interface RemoteOptions {
|
||||
muted?: boolean;
|
||||
camera?: boolean;
|
||||
screen?: boolean;
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
/** Подставная комната: те же методы, что использует `lib/livekit.ts`. */
|
||||
export class FakeRoom {
|
||||
static instances: FakeRoom[] = [];
|
||||
|
||||
static reset(): void {
|
||||
FakeRoom.instances = [];
|
||||
}
|
||||
|
||||
static latest(): FakeRoom {
|
||||
const room = FakeRoom.instances.at(-1);
|
||||
if (room === undefined) {
|
||||
throw new Error('комната LiveKit ещё не создавалась');
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
readonly localParticipant: FakeParticipant;
|
||||
readonly remoteParticipants = new Map<string, FakeParticipant>();
|
||||
readonly connectCalls: { url: string; token: string }[] = [];
|
||||
readonly deviceSwitches: { kind: string; deviceId: string }[] = [];
|
||||
disconnectCalls = 0;
|
||||
startAudioCalls = 0;
|
||||
private readonly handlers = new Map<string, Set<Handler>>();
|
||||
|
||||
constructor(readonly options?: unknown) {
|
||||
FakeRoom.instances.push(this);
|
||||
this.localParticipant = new FakeParticipant(this, 'user-1', 'Alice', true);
|
||||
}
|
||||
|
||||
on(event: string, handler: Handler): this {
|
||||
const handlers = this.handlers.get(event) ?? new Set<Handler>();
|
||||
handlers.add(handler);
|
||||
this.handlers.set(event, handlers);
|
||||
return this;
|
||||
}
|
||||
|
||||
off(event: string, handler: Handler): this {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
removeAllListeners(): this {
|
||||
this.handlers.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
connect(url: string, token: string): Promise<void> {
|
||||
this.connectCalls.push({ url, token });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
disconnect(): Promise<void> {
|
||||
this.disconnectCalls += 1;
|
||||
this.emit(RoomEvent.Disconnected);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
startAudio(): Promise<void> {
|
||||
this.startAudioCalls += 1;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
switchActiveDevice(kind: string, deviceId: string): Promise<boolean> {
|
||||
this.deviceSwitches.push({ kind, deviceId });
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
emit(event: string, ...args: unknown[]): void {
|
||||
for (const handler of [...(this.handlers.get(event) ?? [])]) {
|
||||
(handler as (...values: unknown[]) => void)(...args);
|
||||
}
|
||||
}
|
||||
|
||||
/** Подключает удалённого участника: как `ParticipantConnected` из SDK. */
|
||||
addRemote(userId: string, name: string, options: RemoteOptions = {}): FakeParticipant {
|
||||
const participant = new FakeParticipant(this, userId, name);
|
||||
participant.connectionQuality = options.quality ?? ConnectionQuality.Excellent;
|
||||
participant.setPublication(Track.Source.Microphone, options.muted ?? false);
|
||||
if (options.camera === true) {
|
||||
participant.setPublication(Track.Source.Camera);
|
||||
}
|
||||
if (options.screen === true) {
|
||||
participant.setPublication(Track.Source.ScreenShare);
|
||||
}
|
||||
this.remoteParticipants.set(userId, participant);
|
||||
this.emit(RoomEvent.ParticipantConnected, participant);
|
||||
this.emit(RoomEvent.TrackSubscribed);
|
||||
return participant;
|
||||
}
|
||||
|
||||
removeRemote(userId: string): void {
|
||||
const participant = this.remoteParticipants.get(userId);
|
||||
if (participant === undefined) {
|
||||
return;
|
||||
}
|
||||
this.remoteParticipants.delete(userId);
|
||||
this.emit(RoomEvent.ParticipantDisconnected, participant);
|
||||
}
|
||||
|
||||
/** Кто-то заговорил: `ActiveSpeakersChanged` из SDK. */
|
||||
activeSpeakers(userIds: string[]): void {
|
||||
const speakerIds = new Set(userIds);
|
||||
this.localParticipant.setSpeaking(speakerIds.has(this.localParticipant.identity));
|
||||
for (const participant of this.remoteParticipants.values()) {
|
||||
participant.setSpeaking(speakerIds.has(participant.identity));
|
||||
}
|
||||
this.emit(RoomEvent.ActiveSpeakersChanged, []);
|
||||
}
|
||||
|
||||
setQuality(userId: string, quality: string): void {
|
||||
const participant = this.remoteParticipants.get(userId);
|
||||
if (participant !== undefined) {
|
||||
participant.connectionQuality = quality;
|
||||
this.emit(RoomEvent.ConnectionQualityChanged, participant);
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-2
@@ -13,6 +13,7 @@ import { useMessagesStore } from '@/stores/messages';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
import type { User } from '@/api/types';
|
||||
|
||||
/** Ответ-заглушка с JSON-телом (как у настоящего API). */
|
||||
@@ -32,7 +33,8 @@ export interface FetchRoute {
|
||||
/** Подстрока URL, по которой выбирается обработчик. */
|
||||
match: string;
|
||||
method?: string;
|
||||
response: () => Response | Promise<Response>;
|
||||
/** Ответ на запрос: обработчик видит url, метод и разобранное тело. */
|
||||
response: (request: RecordedRequest) => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +56,7 @@ export function installFetch(routes: FetchRoute[]) {
|
||||
if (!url.includes(route.match)) {
|
||||
continue;
|
||||
}
|
||||
return Promise.resolve(route.response());
|
||||
return Promise.resolve(route.response({ url, method, body: parseRequestBody(init?.body) }));
|
||||
}
|
||||
return Promise.resolve(apiError('not_found', 404));
|
||||
});
|
||||
@@ -62,6 +64,18 @@ export function installFetch(routes: FetchRoute[]) {
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
/** Тело запроса как JSON: у multipart и пустого тела его нет. */
|
||||
function parseRequestBody(body: BodyInit | null | undefined): unknown {
|
||||
if (typeof body !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(body) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Мок fetch, который падает на уровне сети (сервер недоступен). */
|
||||
export function installFailingFetch() {
|
||||
const fetchMock = vi.fn(() => Promise.reject(new TypeError('Failed to fetch')));
|
||||
@@ -136,6 +150,8 @@ export function resetStores(): void {
|
||||
useSessionStore.getState().reset();
|
||||
useMessagesStore.getState().reset();
|
||||
useVoiceStore.getState().reset();
|
||||
// Голосовая сессия могла остаться от прошлого теста: гасим её без запросов.
|
||||
useVoiceConnectionStore.getState().reset();
|
||||
useFriendsStore.getState().reset();
|
||||
resetUiStore();
|
||||
// `reset` закрывает и общее соединение шлюза: иначе колбэки прошлого теста
|
||||
@@ -205,6 +221,18 @@ interface SnapshotGuildInput {
|
||||
my_nickname?: string;
|
||||
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
|
||||
emojis?: { id: string; name: string; file_id: string; animated?: boolean; token?: string }[];
|
||||
/** Кто сидит в голосовых комнатах (AGENT.md 7.14). */
|
||||
voice_states?: {
|
||||
user_id: string;
|
||||
channel_id: string;
|
||||
self_mute?: boolean;
|
||||
self_deaf?: boolean;
|
||||
server_mute?: boolean;
|
||||
server_deaf?: boolean;
|
||||
camera?: boolean;
|
||||
screen?: boolean;
|
||||
joined_at?: string;
|
||||
}[];
|
||||
channels?: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -269,6 +297,18 @@ export function makeReadySnapshot(
|
||||
animated: emoji.animated ?? false,
|
||||
token: emoji.token ?? `<:${emoji.name}:${emoji.file_id}>`,
|
||||
})),
|
||||
voice_states: (guild.voice_states ?? []).map((state, index) => ({
|
||||
user_id: state.user_id,
|
||||
channel_id: state.channel_id,
|
||||
guild_id: guild.id,
|
||||
self_mute: state.self_mute ?? false,
|
||||
self_deaf: state.self_deaf ?? false,
|
||||
server_mute: state.server_mute ?? false,
|
||||
server_deaf: state.server_deaf ?? false,
|
||||
camera: state.camera ?? false,
|
||||
screen: state.screen ?? false,
|
||||
joined_at: state.joined_at ?? `2026-09-19T10:0${String(index)}:00Z`,
|
||||
})),
|
||||
})),
|
||||
dm_channels: dmChannels.map((channel) => ({
|
||||
id: channel.id,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,505 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
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, RoomEvent } from './fakeLiveKit';
|
||||
import {
|
||||
installFetch,
|
||||
makeReadySnapshot,
|
||||
recordedRequests,
|
||||
resetStores,
|
||||
type RecordedRequest,
|
||||
} from './helpers';
|
||||
import { fakeVoiceToken, makeVoiceState, voiceRoutes, type VoiceRoutes } from './voiceRoutes';
|
||||
import { parseGatewaySnapshot } from '@/api/gateway';
|
||||
import { dispatchGatewayEvent } from '@/stores/gateway';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
import {
|
||||
SPEAKING_WARNING_INTERVAL_MS,
|
||||
SPEAKING_WARNING_VISIBLE_MS,
|
||||
TOKEN_REFRESH_MIN_DELAY_MS,
|
||||
useVoiceConnectionStore,
|
||||
VOICE_SESSION_EXPIRED_CODE,
|
||||
} from '@/stores/voiceConnection';
|
||||
|
||||
const ALL_PERMISSIONS = [
|
||||
'VIEW_GUILD',
|
||||
'CONNECT_VOICE',
|
||||
'MUTE_MEMBERS',
|
||||
'DEAFEN_MEMBERS',
|
||||
'MOVE_MEMBERS',
|
||||
];
|
||||
|
||||
/** Сессия: пользователь и сервер с двумя голосовыми комнатами. */
|
||||
function seedSession(voiceStates: Parameters<typeof makeVoiceState>[0][] = []): void {
|
||||
const snapshot = parseGatewaySnapshot(
|
||||
makeReadySnapshot([
|
||||
{
|
||||
id: 'g-1',
|
||||
name: 'Сервер',
|
||||
my_permissions: ALL_PERMISSIONS,
|
||||
channels: [
|
||||
{ id: 'c-1', name: 'общий' },
|
||||
{ id: 'c-voice', name: 'Голосовая', type: 'voice' },
|
||||
{ id: 'c-other', name: 'Вторая', type: 'voice' },
|
||||
],
|
||||
voice_states: voiceStates,
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (snapshot === null) {
|
||||
throw new Error('снимок READY не разобрался');
|
||||
}
|
||||
useSessionStore.getState().applyReady(snapshot);
|
||||
}
|
||||
|
||||
/** Ждёт выполнения цепочки промисов (в том числе под фейковыми таймерами). */
|
||||
async function flush(times = 12): Promise<void> {
|
||||
for (let index = 0; index < times; index += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/** Последний запрос, подходящий под фильтр. */
|
||||
function lastRequest(
|
||||
fetchMock: ReturnType<typeof installFetch>,
|
||||
predicate: { url: string; method?: string },
|
||||
): RecordedRequest | undefined {
|
||||
const matching = recordedRequests(fetchMock).filter(
|
||||
(request) =>
|
||||
request.url.includes(predicate.url) &&
|
||||
(predicate.method === undefined || request.method === predicate.method.toUpperCase()),
|
||||
);
|
||||
return matching.at(-1);
|
||||
}
|
||||
|
||||
/** Вход в комнату «как есть»: с готовыми маршрутами и сессией. */
|
||||
async function joinRoom(
|
||||
channelId = 'c-voice',
|
||||
voice: VoiceRoutes = voiceRoutes(),
|
||||
): Promise<ReturnType<typeof installFetch>> {
|
||||
const fetchMock = installFetch([...voice.routes]);
|
||||
await useVoiceConnectionStore.getState().join('g-1', channelId);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
FakeRoom.reset();
|
||||
seedSession();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('голосовая сессия: подключение', () => {
|
||||
it('входит в комнату по токену и публикует микрофон', async () => {
|
||||
const voice = voiceRoutes();
|
||||
const fetchMock = installFetch([...voice.routes]);
|
||||
|
||||
await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
|
||||
|
||||
const state = useVoiceConnectionStore.getState();
|
||||
expect(state.status).toBe('connected');
|
||||
expect(state.guildId).toBe('g-1');
|
||||
expect(state.channelId).toBe('c-voice');
|
||||
expect(state.error).toBeNull();
|
||||
expect(state.participants.map((participant) => participant.userId)).toEqual(['user-1']);
|
||||
|
||||
const [call] = FakeRoom.latest().connectCalls;
|
||||
expect(call?.url).toBe('wss://gl.localhost/rtc');
|
||||
expect(call?.token).not.toBe('');
|
||||
expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
|
||||
method: 'setMicrophoneEnabled',
|
||||
value: true,
|
||||
});
|
||||
|
||||
// Сервер уже знает о нас: состояние легло в снапшот сессии.
|
||||
expect(lastRequest(fetchMock, { url: '/voice/join', method: 'POST' })).toBeDefined();
|
||||
expect(
|
||||
useSessionStore.getState().voiceStates['g-1']?.some((item) => item.user_id === 'user-1'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('показывает участников комнаты из SDK с речью и качеством связи', async () => {
|
||||
await joinRoom();
|
||||
const room = FakeRoom.latest();
|
||||
room.addRemote('user-2', 'Bob', { quality: 'poor' });
|
||||
room.activeSpeakers(['user-2']);
|
||||
|
||||
const state = useVoiceConnectionStore.getState();
|
||||
expect(state.participants.map((participant) => participant.userId)).toEqual([
|
||||
'user-1',
|
||||
'user-2',
|
||||
]);
|
||||
const bob = state.participants.find((participant) => participant.userId === 'user-2');
|
||||
expect(bob?.isSpeaking).toBe(true);
|
||||
expect(bob?.quality).toBe('poor');
|
||||
expect(bob?.audio).toBeDefined();
|
||||
});
|
||||
|
||||
it('повторный вход в ту же комнату ничего не делает', async () => {
|
||||
const voice = voiceRoutes();
|
||||
const fetchMock = await joinRoom('c-voice', voice);
|
||||
|
||||
await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
|
||||
|
||||
expect(voice.joinCount()).toBe(1);
|
||||
expect(lastRequest(fetchMock, { url: '/voice/join', method: 'POST' })).toBeDefined();
|
||||
});
|
||||
|
||||
it('переходит в другую комнату через выход и вход', async () => {
|
||||
const voice = voiceRoutes({ channelIds: ['c-voice', 'c-other'] });
|
||||
const fetchMock = installFetch([...voice.routes]);
|
||||
|
||||
await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
|
||||
await useVoiceConnectionStore.getState().join('g-1', 'c-other');
|
||||
|
||||
const voiceRequests = recordedRequests(fetchMock)
|
||||
.filter((request) => request.url.includes('/voice/'))
|
||||
.map((request) => `${request.method} ${request.url.split('/api/v1')[1] ?? ''}`);
|
||||
expect(voiceRequests).toEqual([
|
||||
'POST /channels/c-voice/voice/join',
|
||||
'POST /channels/c-voice/voice/leave',
|
||||
'POST /channels/c-other/voice/join',
|
||||
]);
|
||||
expect(useVoiceConnectionStore.getState().channelId).toBe('c-other');
|
||||
});
|
||||
|
||||
it('выходит из комнаты и очищает состояние', async () => {
|
||||
const fetchMock = await joinRoom();
|
||||
FakeRoom.latest().addRemote('user-2', 'Bob');
|
||||
|
||||
await useVoiceConnectionStore.getState().leave();
|
||||
|
||||
const state = useVoiceConnectionStore.getState();
|
||||
expect(state.status).toBe('idle');
|
||||
expect(state.channelId).toBeNull();
|
||||
expect(state.participants).toEqual([]);
|
||||
expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
|
||||
expect(FakeRoom.latest().disconnectCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('голосовая сессия: флаги', () => {
|
||||
it('кнопки микрофона, камеры и наушников управляют SDK и уходят на сервер', async () => {
|
||||
const fetchMock = await joinRoom();
|
||||
const store = useVoiceConnectionStore.getState();
|
||||
|
||||
await store.setMicEnabled(false);
|
||||
expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
|
||||
method: 'setMicrophoneEnabled',
|
||||
value: false,
|
||||
});
|
||||
expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
|
||||
self_mute: true,
|
||||
});
|
||||
|
||||
await useVoiceConnectionStore.getState().setCameraEnabled(true);
|
||||
expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
|
||||
method: 'setCameraEnabled',
|
||||
value: true,
|
||||
});
|
||||
expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
|
||||
camera: true,
|
||||
});
|
||||
|
||||
await useVoiceConnectionStore.getState().setDeafened(true);
|
||||
expect(useVoiceConnectionStore.getState().deafened).toBe(true);
|
||||
expect(useVoiceStore.getState().headphonesEnabled).toBe(false);
|
||||
expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
|
||||
self_deaf: true,
|
||||
});
|
||||
|
||||
await useVoiceConnectionStore.getState().setScreenShareEnabled(true);
|
||||
expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
|
||||
method: 'setScreenShareEnabled',
|
||||
value: true,
|
||||
});
|
||||
expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
|
||||
screen: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('без подключения кнопки только сохраняют предпочтения', async () => {
|
||||
const fetchMock = installFetch([]);
|
||||
|
||||
await useVoiceConnectionStore.getState().setMicEnabled(false);
|
||||
|
||||
expect(useVoiceStore.getState().micEnabled).toBe(false);
|
||||
expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('серверный мьют выключает микрофон и блокирует включение', async () => {
|
||||
const fetchMock = await joinRoom();
|
||||
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'VOICE_STATE_UPDATE',
|
||||
s: 1,
|
||||
d: makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_mute: true }),
|
||||
});
|
||||
await flush();
|
||||
|
||||
const state = useVoiceConnectionStore.getState();
|
||||
expect(state.serverMuted).toBe(true);
|
||||
expect(state.micEnabled).toBe(false);
|
||||
expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
|
||||
method: 'setMicrophoneEnabled',
|
||||
value: false,
|
||||
});
|
||||
|
||||
// Пользователь не может включить микрофон, пока модератор не снимет мьют.
|
||||
await state.setMicEnabled(true);
|
||||
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
|
||||
expect(useVoiceStore.getState().micEnabled).toBe(true);
|
||||
|
||||
// Модератор снял мьют — кнопка снова доступна.
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'VOICE_STATE_UPDATE',
|
||||
s: 2,
|
||||
d: makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_mute: false }),
|
||||
});
|
||||
await flush();
|
||||
expect(useVoiceConnectionStore.getState().serverMuted).toBe(false);
|
||||
await useVoiceConnectionStore.getState().setMicEnabled(true);
|
||||
expect(useVoiceConnectionStore.getState().micEnabled).toBe(true);
|
||||
expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
|
||||
self_mute: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('серверное глушение блокирует кнопку наушников', async () => {
|
||||
await joinRoom();
|
||||
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'VOICE_STATE_UPDATE',
|
||||
s: 1,
|
||||
d: makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_deaf: true }),
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(useVoiceConnectionStore.getState().serverDeafened).toBe(true);
|
||||
expect(useVoiceConnectionStore.getState().deafened).toBe(true);
|
||||
});
|
||||
|
||||
it('личная громкость участника хранится локально', async () => {
|
||||
await joinRoom();
|
||||
|
||||
useVoiceConnectionStore.getState().setVolume('user-2', 40);
|
||||
|
||||
expect(useVoiceStore.getState().participantVolumes['user-2']).toBe(40);
|
||||
});
|
||||
|
||||
it('перемещение и модерация уходят на сервер', async () => {
|
||||
const voice = voiceRoutes({
|
||||
states: [makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' })],
|
||||
});
|
||||
const fetchMock = await joinRoom('c-voice', voice);
|
||||
|
||||
await useVoiceConnectionStore.getState().setParticipantMuted('g-1', 'user-2', true);
|
||||
expect(voice.states.find((item) => item.user_id === 'user-2')?.server_mute).toBe(true);
|
||||
expect(lastRequest(fetchMock, { url: '/voice-states/user-2', method: 'PATCH' })?.body).toEqual({
|
||||
server_mute: true,
|
||||
});
|
||||
|
||||
await useVoiceConnectionStore.getState().setParticipantDeafened('g-1', 'user-2', true);
|
||||
expect(voice.states.find((item) => item.user_id === 'user-2')?.server_deaf).toBe(true);
|
||||
expect(lastRequest(fetchMock, { url: '/voice-states/user-2', method: 'PATCH' })?.body).toEqual({
|
||||
server_deaf: true,
|
||||
});
|
||||
|
||||
await useVoiceConnectionStore.getState().moveParticipant('g-1', 'user-2', 'c-other');
|
||||
expect(voice.states.find((item) => item.user_id === 'user-2')?.channel_id).toBe('c-other');
|
||||
expect(
|
||||
lastRequest(fetchMock, { url: '/voice-states/user-2/move', method: 'POST' })?.body,
|
||||
).toEqual({ channel_id: 'c-other' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('голосовая сессия: синхронизация с сервером', () => {
|
||||
it('VOICE_STATE_UPDATE добавляет и убирает участника', async () => {
|
||||
await joinRoom();
|
||||
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'VOICE_STATE_UPDATE',
|
||||
s: 1,
|
||||
d: makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' }),
|
||||
});
|
||||
expect(
|
||||
useSessionStore.getState().voiceStates['g-1']?.some((item) => item.user_id === 'user-2'),
|
||||
).toBe(true);
|
||||
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'VOICE_STATE_UPDATE',
|
||||
s: 2,
|
||||
d: { guild_id: 'g-1', channel_id: 'c-voice', user_id: 'user-2', left: true },
|
||||
});
|
||||
expect(
|
||||
useSessionStore.getState().voiceStates['g-1']?.some((item) => item.user_id === 'user-2'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('переподключается в комнату, куда нас переместил модератор', async () => {
|
||||
const voice = voiceRoutes({ channelIds: ['c-voice', 'c-other'] });
|
||||
const fetchMock = installFetch([...voice.routes]);
|
||||
await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
|
||||
const first = FakeRoom.latest();
|
||||
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'VOICE_STATE_UPDATE',
|
||||
s: 1,
|
||||
d: makeVoiceState({ user_id: 'user-1', channel_id: 'c-other' }),
|
||||
});
|
||||
await flush(24);
|
||||
|
||||
expect(useVoiceConnectionStore.getState().channelId).toBe('c-other');
|
||||
// Старая комната закрыта, для новой создана своя сессия SDK.
|
||||
expect(first.disconnectCalls).toBe(1);
|
||||
expect(FakeRoom.instances).toHaveLength(2);
|
||||
expect(FakeRoom.latest().connectCalls).toHaveLength(1);
|
||||
expect(
|
||||
lastRequest(fetchMock, { url: '/channels/c-other/voice/join', method: 'POST' }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('окончательный разрыв соединения возвращает в idle и шлёт leave', async () => {
|
||||
const fetchMock = await joinRoom();
|
||||
|
||||
FakeRoom.latest().emit(RoomEvent.Disconnected);
|
||||
await flush(24);
|
||||
|
||||
expect(useVoiceConnectionStore.getState().status).toBe('idle');
|
||||
expect(useVoiceConnectionStore.getState().participants).toEqual([]);
|
||||
expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
|
||||
});
|
||||
|
||||
it('во время переподключения показывает статус reconnecting', async () => {
|
||||
await joinRoom();
|
||||
|
||||
FakeRoom.latest().emit(RoomEvent.Reconnecting);
|
||||
expect(useVoiceConnectionStore.getState().status).toBe('reconnecting');
|
||||
|
||||
FakeRoom.latest().emit(RoomEvent.Reconnected);
|
||||
expect(useVoiceConnectionStore.getState().status).toBe('connected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('голосовая сессия: продление токена', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-09-19T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('за минуту до истечения берёт новый токен и переподключается с флагами', async () => {
|
||||
const first = fakeVoiceToken(30);
|
||||
const second = fakeVoiceToken(3600);
|
||||
const voice = voiceRoutes({ tokens: [first, second] });
|
||||
const fetchMock = await joinRoom('c-voice', voice);
|
||||
|
||||
await useVoiceConnectionStore.getState().setMicEnabled(false);
|
||||
await useVoiceConnectionStore.getState().setCameraEnabled(true);
|
||||
|
||||
// exp через 30 секунд: продление планируется на минимальную задержку.
|
||||
await vi.advanceTimersByTimeAsync(TOKEN_REFRESH_MIN_DELAY_MS + 1);
|
||||
await flush(24);
|
||||
|
||||
const room = FakeRoom.latest();
|
||||
expect(room.connectCalls.map((call) => call.token)).toEqual([first, second]);
|
||||
expect(room.disconnectCalls).toBe(1);
|
||||
expect(voice.joinCount()).toBe(2);
|
||||
// Флаги применились заново и подтверждены на сервере.
|
||||
expect(
|
||||
room.localParticipant.calls.filter((call) => call.method === 'setMicrophoneEnabled'),
|
||||
).toEqual([
|
||||
{ method: 'setMicrophoneEnabled', value: true },
|
||||
{ method: 'setMicrophoneEnabled', value: false },
|
||||
{ method: 'setMicrophoneEnabled', value: false },
|
||||
]);
|
||||
expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
|
||||
self_mute: true,
|
||||
self_deaf: false,
|
||||
camera: true,
|
||||
screen: false,
|
||||
});
|
||||
expect(useVoiceConnectionStore.getState().status).toBe('connected');
|
||||
expect(useVoiceConnectionStore.getState().refreshing).toBe(false);
|
||||
});
|
||||
|
||||
it('после трёх неудач выходит из комнаты и показывает ошибку', async () => {
|
||||
const voice = voiceRoutes({
|
||||
tokens: [fakeVoiceToken(30)],
|
||||
joinSuccesses: 1,
|
||||
});
|
||||
const fetchMock = await joinRoom('c-voice', voice);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(TOKEN_REFRESH_MIN_DELAY_MS + 1);
|
||||
await flush(40);
|
||||
|
||||
const state = useVoiceConnectionStore.getState();
|
||||
expect(voice.joinCount()).toBe(4);
|
||||
expect(state.status).toBe('error');
|
||||
expect(state.error).toEqual({ code: VOICE_SESSION_EXPIRED_CODE });
|
||||
expect(state.participants).toEqual([]);
|
||||
expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('голосовая сессия: предупреждение о речи', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-09-19T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('показывает подсказку не чаще одного раза в 5 секунд', async () => {
|
||||
await joinRoom();
|
||||
await useVoiceConnectionStore.getState().setMicEnabled(false);
|
||||
const room = FakeRoom.latest();
|
||||
|
||||
room.activeSpeakers(['user-1']);
|
||||
expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(true);
|
||||
|
||||
// Подсказка сама исчезает, но раньше пяти секунд не повторяется.
|
||||
await vi.advanceTimersByTimeAsync(SPEAKING_WARNING_VISIBLE_MS + 1);
|
||||
expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(false);
|
||||
room.activeSpeakers(['user-1']);
|
||||
expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SPEAKING_WARNING_INTERVAL_MS);
|
||||
room.activeSpeakers(['user-1']);
|
||||
expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(true);
|
||||
});
|
||||
|
||||
it('молчит, когда микрофон включён', async () => {
|
||||
await joinRoom();
|
||||
|
||||
FakeRoom.latest().activeSpeakers(['user-1']);
|
||||
|
||||
expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(false);
|
||||
});
|
||||
|
||||
it('подсказку можно скрыть вручную', async () => {
|
||||
await joinRoom();
|
||||
await useVoiceConnectionStore.getState().setMicEnabled(false);
|
||||
FakeRoom.latest().activeSpeakers(['user-1']);
|
||||
|
||||
useVoiceConnectionStore.getState().dismissSpeakingWarning();
|
||||
|
||||
expect(useVoiceConnectionStore.getState().speakingWhileMuted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { apiError, json, type FetchRoute } from './helpers';
|
||||
import type { VoiceState } from '@/api/voice';
|
||||
|
||||
/**
|
||||
* Маршруты голосовых ручек для тестов: вход/выход, свои флаги, модерация и
|
||||
* перемещение. Состояние живёт в замыкании, поэтому ответы согласованы между
|
||||
* запросами (как у настоящего сервера).
|
||||
*/
|
||||
|
||||
/** JWT с `exp` через `expiresInSeconds`: из него клиент считает время продления. */
|
||||
export function fakeVoiceToken(expiresInSeconds = 3600): string {
|
||||
const exp = Math.floor(Date.now() / 1000) + expiresInSeconds;
|
||||
const payload = btoa(JSON.stringify({ exp }))
|
||||
.replace(/=+$/u, '')
|
||||
.replace(/\+/gu, '-')
|
||||
.replace(/\//gu, '_');
|
||||
return `header.${payload}.signature`;
|
||||
}
|
||||
|
||||
export function makeVoiceState(
|
||||
overrides: Partial<VoiceState> & Pick<VoiceState, 'user_id' | 'channel_id'>,
|
||||
): VoiceState {
|
||||
return {
|
||||
guild_id: 'g-1',
|
||||
self_mute: false,
|
||||
self_deaf: false,
|
||||
server_mute: false,
|
||||
server_deaf: false,
|
||||
camera: false,
|
||||
screen: false,
|
||||
joined_at: '2026-09-19T10:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface VoiceRoutesOptions {
|
||||
guildId?: string;
|
||||
/** Комнаты, для которых работает вход (по умолчанию одна). */
|
||||
channelIds?: string[];
|
||||
/** Токены, которые выдаёт join по порядку; последний повторяется. */
|
||||
tokens?: string[];
|
||||
/** Стартовое состояние сервера: кто в комнатах. */
|
||||
states?: VoiceState[];
|
||||
/** Код ошибки входа (`voice.channel_full`, `perm.denied`, ...). */
|
||||
joinError?: { code: string; status: number };
|
||||
/** Сколько первых входов проходят успешно (для проверки продления токена). */
|
||||
joinSuccesses?: number;
|
||||
}
|
||||
|
||||
export interface VoiceRoutes {
|
||||
routes: FetchRoute[];
|
||||
/** Текущее состояние «сервера»: его проверяют тесты. */
|
||||
states: VoiceState[];
|
||||
/** Сколько раз запросили вход. */
|
||||
joinCount: () => number;
|
||||
}
|
||||
|
||||
export function voiceRoutes(options: VoiceRoutesOptions = {}): VoiceRoutes {
|
||||
const guildId = options.guildId ?? 'g-1';
|
||||
const channelIds = options.channelIds ?? ['c-voice'];
|
||||
const tokens = options.tokens ?? [fakeVoiceToken()];
|
||||
const states = [...(options.states ?? [])];
|
||||
let issued = 0;
|
||||
let joins = 0;
|
||||
|
||||
const nextToken = (): string => {
|
||||
const token = tokens[Math.min(issued, tokens.length - 1)];
|
||||
issued += 1;
|
||||
return token ?? '';
|
||||
};
|
||||
|
||||
const list = (): VoiceState[] => states;
|
||||
|
||||
const routes: FetchRoute[] = [
|
||||
{
|
||||
match: '/voice/join',
|
||||
method: 'POST',
|
||||
response: ({ url }) => {
|
||||
joins += 1;
|
||||
if (options.joinError !== undefined) {
|
||||
return apiError(options.joinError.code, options.joinError.status);
|
||||
}
|
||||
if (options.joinSuccesses !== undefined && joins > options.joinSuccesses) {
|
||||
return apiError('internal.error', 500);
|
||||
}
|
||||
const channelId =
|
||||
channelIds.find((id) => url.includes(`/channels/${id}/`)) ?? channelIds[0] ?? '';
|
||||
const state = makeVoiceState({
|
||||
user_id: 'user-1',
|
||||
channel_id: channelId,
|
||||
guild_id: guildId,
|
||||
self_mute: false,
|
||||
});
|
||||
const index = states.findIndex((item) => item.user_id === 'user-1');
|
||||
if (index === -1) {
|
||||
states.push(state);
|
||||
} else {
|
||||
states[index] = state;
|
||||
}
|
||||
return json({
|
||||
token: nextToken(),
|
||||
url: 'wss://gl.localhost/rtc',
|
||||
room: `guild-${guildId}-${channelId}`,
|
||||
channel_id: channelId,
|
||||
guild_id: guildId,
|
||||
state,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
match: '/voice/leave',
|
||||
method: 'POST',
|
||||
response: () => {
|
||||
const index = states.findIndex((item) => item.user_id === 'user-1');
|
||||
if (index >= 0) {
|
||||
states.splice(index, 1);
|
||||
}
|
||||
return json({ ok: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
match: `/guilds/${guildId}/voice-states/@me`,
|
||||
method: 'PATCH',
|
||||
response: ({ body }) => {
|
||||
const patch = (body ?? {}) as Record<string, unknown>;
|
||||
const index = states.findIndex((item) => item.user_id === 'user-1');
|
||||
const current =
|
||||
index === -1 ? makeVoiceState({ user_id: 'user-1', channel_id: '' }) : states[index];
|
||||
if (current !== undefined) {
|
||||
const next: VoiceState = { ...current };
|
||||
if (typeof patch['self_mute'] === 'boolean') {
|
||||
next.self_mute = patch['self_mute'];
|
||||
}
|
||||
if (typeof patch['self_deaf'] === 'boolean') {
|
||||
next.self_deaf = patch['self_deaf'];
|
||||
}
|
||||
if (typeof patch['camera'] === 'boolean') {
|
||||
next.camera = patch['camera'];
|
||||
}
|
||||
if (typeof patch['screen'] === 'boolean') {
|
||||
next.screen = patch['screen'];
|
||||
}
|
||||
if (index === -1) {
|
||||
states.push(next);
|
||||
} else {
|
||||
states[index] = next;
|
||||
}
|
||||
}
|
||||
return json({ voice_states: list() });
|
||||
},
|
||||
},
|
||||
{
|
||||
match: `/guilds/${guildId}/voice-states/`,
|
||||
method: 'PATCH',
|
||||
response: ({ url, body }) => {
|
||||
const userId = url.split('/voice-states/')[1]?.split(/[/?]/u)[0] ?? '';
|
||||
const patch = (body ?? {}) as Record<string, unknown>;
|
||||
const index = states.findIndex((item) => item.user_id === userId);
|
||||
const current = states[index];
|
||||
if (current !== undefined) {
|
||||
if (typeof patch['server_mute'] === 'boolean') {
|
||||
current.server_mute = patch['server_mute'];
|
||||
}
|
||||
if (typeof patch['server_deaf'] === 'boolean') {
|
||||
current.server_deaf = patch['server_deaf'];
|
||||
}
|
||||
}
|
||||
return json({ voice_states: list() });
|
||||
},
|
||||
},
|
||||
{
|
||||
match: `/guilds/${guildId}/voice-states/`,
|
||||
method: 'POST',
|
||||
response: ({ url, body }) => {
|
||||
const userId = url.split('/voice-states/')[1]?.split('/')[0] ?? '';
|
||||
const target = ((body ?? {}) as Record<string, unknown>)['channel_id'];
|
||||
const index = states.findIndex((item) => item.user_id === userId);
|
||||
const current = states[index];
|
||||
if (current !== undefined && typeof target === 'string') {
|
||||
current.channel_id = target;
|
||||
}
|
||||
return json({ voice_states: list() });
|
||||
},
|
||||
},
|
||||
{
|
||||
match: `/guilds/${guildId}/voice-states`,
|
||||
response: () => json({ voice_states: list() }),
|
||||
},
|
||||
];
|
||||
|
||||
return { routes, states, joinCount: () => joins };
|
||||
}
|
||||
Reference in New Issue
Block a user