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[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 { for (let index = 0; index < times; index += 1) { await Promise.resolve(); } } /** Последний запрос, подходящий под фильтр. */ function lastRequest( fetchMock: ReturnType, 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> { 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); }); });