import { fireEvent, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { currentRingtone } from '@/lib/ringtone'; import { useDmCallStore } from '@/stores/dmCall'; import { useSessionStore } from '@/stores/session'; import { dmCallRoutes, makeCallParticipant, makeDmCall } from './dmCallRoutes'; import { installFakeAudioContext } from './fakeAudio'; import { FakeRoom } from './fakeLiveKit'; import { findRequest, installFetch, installGatewaySocket, json, makeReadySnapshot, makeUser, recordedRequests, renderApp, } from './helpers'; 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, }; }); /** * Ответы оболочки: мета с включённым голосом (без неё кнопок звонка нет), * профиль, инстанс, серверы, связи и список бесед. */ const baseRoutes = [ { match: '/api/v1/meta', response: () => json({ features: { voice_enabled: true } }) }, { match: '/api/v1/users/@me/relationships', response: () => json({ friends: [], incoming: [], outgoing: [], blocked: [] }), }, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, { match: '/api/v1/users/@me/channels', response: () => json({ channels: [] }), }, { match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) }, { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test', registration_enabled: true } }), }, ]; /** Беседа 1:1 с Бобом в снапшоте READY. */ const dmChannel = { id: 'c-dm', name: 'Bob', recipient_id: 'user-2', status: 'online', }; /** Снапшот со звонком в беседе: Алиса приглашена, Боб уже «в комнате». */ function ringingCallSnapshot() { return makeReadySnapshot( [], [ { ...dmChannel, active_call: makeDmCall({ id: 'call-7', channel_id: 'c-dm', initiator_id: 'user-2', media: 'video', participants: [ makeCallParticipant('user-2', 'joined'), makeCallParticipant('user-1', 'invited'), ], }), }, ], ); } /** Поднимает приложение на беседе с подключённым шлюзом. */ async function bootDm(options: { snapshot?: unknown; routes: Parameters[0]; path?: string; }) { const fetchMock = installFetch([...baseRoutes, ...options.routes]); const gateway = installGatewaySocket(options.snapshot ?? makeReadySnapshot([], [dmChannel])); renderApp(options.path ?? '/app/friends/c-dm'); await gateway.greet(); await screen.findByTestId('dm-header'); return { fetchMock, gateway }; } describe('звонки в беседах', () => { beforeEach(() => { FakeRoom.reset(); installFakeAudioContext(); }); it('начинает аудиозвонок из шапки и подключается к комнате LiveKit', async () => { const calls = dmCallRoutes(); const { fetchMock } = await bootDm({ routes: calls.routes }); fireEvent.click(screen.getByTestId('dm-call-menu')); fireEvent.click(await screen.findByTestId('dm-call-audio')); await waitFor(() => { expect(findRequest(fetchMock, { url: '/channels/c-dm/call', method: 'POST' })).toBeDefined(); }); const start = recordedRequests(fetchMock).find( (request) => request.method === 'POST' && request.url.includes('/channels/c-dm/call'), ); expect(start?.body).toEqual({ media: 'audio' }); // Комната поднялась по адресу из ответа сервера, панель звонка на месте. await waitFor(() => { expect(FakeRoom.latest().connectCalls).toHaveLength(1); }); expect(FakeRoom.latest().connectCalls[0]?.url).toBe('wss://gl.localhost/rtc'); expect(await screen.findByTestId('dm-call-panel')).toBeInTheDocument(); expect(await screen.findByTestId('dm-call-tile-user-1')).toBeInTheDocument(); // Свои гудки: звонок ещё ringing, играет исходящий сигнал. expect(currentRingtone()).toBe('outgoing'); expect(useDmCallStore.getState().channelId).toBe('c-dm'); }); it('показывает входящий звонок, играет рингтон и принимает его', async () => { const calls = dmCallRoutes({ call: makeDmCall({ id: 'call-7', channel_id: 'c-dm', initiator_id: 'user-2', media: 'video', participants: [ makeCallParticipant('user-2', 'joined'), makeCallParticipant('user-1', 'invited'), ], }), }); const { fetchMock } = await bootDm({ snapshot: ringingCallSnapshot(), routes: calls.routes }); const card = await screen.findByTestId('dm-incoming-call'); expect(card).toBeInTheDocument(); expect(screen.getByTestId('dm-incoming-call-kind')).toHaveTextContent('Входящий видеозвонок'); // Рингтон входящего играет, пока мы не ответили. await waitFor(() => { expect(currentRingtone()).toBe('incoming'); }); fireEvent.click(screen.getByTestId('dm-incoming-call-accept')); await waitFor(() => { expect( findRequest(fetchMock, { url: '/channels/c-dm/call/accept', method: 'POST' }), ).toBeDefined(); }); expect(await screen.findByTestId('dm-call-panel')).toBeInTheDocument(); await waitFor(() => { expect(currentRingtone()).toBeNull(); }); }); it('отклоняет входящий звонок и убирает карточку', async () => { const calls = dmCallRoutes({ call: makeDmCall({ id: 'call-7', channel_id: 'c-dm', initiator_id: 'user-2', participants: [makeCallParticipant('user-1', 'invited')], }), }); const { fetchMock } = await bootDm({ snapshot: ringingCallSnapshot(), routes: calls.routes }); fireEvent.click(await screen.findByTestId('dm-incoming-call-decline')); await waitFor(() => { expect( findRequest(fetchMock, { url: '/channels/c-dm/call/decline', method: 'POST' }), ).toBeDefined(); }); // Локальная сессия не поднималась: комнаты LiveKit нет. expect(FakeRoom.instances).toHaveLength(0); }); it('показывает «идёт звонок» в списке и шапке по событию и убирает после завершения', async () => { const calls = dmCallRoutes(); const { gateway } = await bootDm({ routes: calls.routes }); expect(screen.queryByTestId('dm-call-c-dm')).not.toBeInTheDocument(); expect(screen.queryByTestId('dm-call-header-status')).not.toBeInTheDocument(); gateway.dispatch( 'DM_CALL_RING', makeDmCall({ id: 'call-9', channel_id: 'c-dm', initiator_id: 'user-2', participants: [ makeCallParticipant('user-2', 'joined'), makeCallParticipant('user-1', 'invited'), ], }), ); expect(await screen.findByTestId('dm-call-c-dm')).toBeInTheDocument(); expect(screen.getByTestId('dm-call-header-status')).toHaveTextContent('Идёт звонок'); expect(screen.getByTestId('dm-call-c-dm')).toHaveAttribute('data-status', 'ringing'); expect(await screen.findByTestId('dm-incoming-call')).toBeInTheDocument(); gateway.dispatch( 'DM_CALL_END', makeDmCall({ id: 'call-9', channel_id: 'c-dm', initiator_id: 'user-2', status: 'ended', end_reason: 'canceled', participants: [ makeCallParticipant('user-2', 'left'), makeCallParticipant('user-1', 'missed'), ], }), ); await waitFor(() => { expect(screen.queryByTestId('dm-call-c-dm')).not.toBeInTheDocument(); }); expect(screen.queryByTestId('dm-call-header-status')).not.toBeInTheDocument(); expect(screen.queryByTestId('dm-incoming-call')).not.toBeInTheDocument(); expect(currentRingtone()).toBeNull(); }); it('обновляет флаги звонка на сервере и в комнате', async () => { const activeCall = makeDmCall({ id: 'call-7', channel_id: 'c-dm', initiator_id: 'user-2', participants: [ makeCallParticipant('user-2', 'joined'), makeCallParticipant('user-1', 'invited'), ], }); const calls = dmCallRoutes({ call: activeCall }); const { fetchMock } = await bootDm({ snapshot: makeReadySnapshot([], [{ ...dmChannel, active_call: activeCall }]), routes: calls.routes, }); fireEvent.click(await screen.findByTestId('dm-incoming-call-accept')); await screen.findByTestId('dm-call-panel'); fireEvent.click(screen.getByTestId('dm-call-mic')); await waitFor(() => { expect( findRequest(fetchMock, { url: '/channels/c-dm/call/@me', method: 'PATCH' }), ).toBeDefined(); }); const patch = recordedRequests(fetchMock).find( (request) => request.method === 'PATCH' && request.url.includes('/call/@me'), ); expect(patch?.body).toEqual({ self_mute: true }); // Микрофон выключен и на стороне SDK. const micCalls = FakeRoom.latest().localParticipant.calls.filter( (call) => call.method === 'setMicrophoneEnabled', ); expect(micCalls.at(-1)?.value).toBe(false); fireEvent.click(screen.getByTestId('dm-call-camera')); await waitFor(() => { const cameraCalls = FakeRoom.latest().localParticipant.calls.filter( (call) => call.method === 'setCameraEnabled', ); expect(cameraCalls.at(-1)?.value).toBe(true); }); fireEvent.click(screen.getByTestId('dm-call-screen')); await waitFor(() => { expect(FakeRoom.latest().localParticipant.screenOptions.at(-1)?.enabled).toBe(true); }); // Выход завершает звонок и гасит сессию. fireEvent.click(screen.getByTestId('dm-call-leave')); await waitFor(() => { expect( findRequest(fetchMock, { url: '/channels/c-dm/call/end', method: 'POST' }), ).toBeDefined(); }); await waitFor(() => { expect(useDmCallStore.getState().channelId).toBeNull(); }); }); it('показывает историю звонков с пропущенными и длительностью', async () => { const calls = dmCallRoutes({ history: [ makeDmCall({ id: 'call-2', channel_id: 'c-dm', initiator_id: 'user-2', status: 'ended', end_reason: 'missed', started_at: '2026-09-26T09:00:00Z', ended_at: '2026-09-26T09:00:00Z', participants: [ makeCallParticipant('user-2', 'left'), makeCallParticipant('user-1', 'missed'), ], }), makeDmCall({ id: 'call-1', channel_id: 'c-dm', initiator_id: 'user-1', status: 'ended', end_reason: 'completed', duration_seconds: 125, started_at: '2026-09-26T08:00:00Z', ended_at: '2026-09-26T08:02:05Z', participants: [ makeCallParticipant('user-1', 'left'), makeCallParticipant('user-2', 'left'), ], }), ], }); await bootDm({ routes: calls.routes }); fireEvent.click(screen.getByTestId('dm-call-menu')); fireEvent.click(await screen.findByTestId('dm-call-history')); const panel = await screen.findByTestId('dm-call-history'); expect(panel).toBeInTheDocument(); expect(await screen.findByTestId('dm-call-history-call-2')).toHaveAttribute( 'data-missed', 'true', ); expect(screen.getByTestId('dm-call-history-call-1')).toHaveAttribute('data-missed', 'false'); expect(screen.getByTestId('dm-call-duration-call-1')).toHaveTextContent('2 мин 5 с'); }); it('не предлагает звонок, когда голос на инстансе выключен', async () => { const calls = dmCallRoutes(); const fetchMock = installFetch([ { match: '/api/v1/meta', response: () => json({ features: { voice_enabled: false } }) }, ...baseRoutes.slice(1), ...calls.routes, ]); const gateway = installGatewaySocket(ringingCallSnapshot()); renderApp('/app/friends/c-dm'); await gateway.greet(); await screen.findByTestId('dm-header'); expect(screen.queryByTestId('dm-call-menu')).not.toBeInTheDocument(); expect(screen.queryByTestId('dm-incoming-call')).not.toBeInTheDocument(); expect(currentRingtone()).toBeNull(); expect(fetchMock).toBeDefined(); }); it('переживает перезагрузку: маркер звонка приходит в READY', async () => { const calls = dmCallRoutes(); await bootDm({ snapshot: ringingCallSnapshot(), routes: calls.routes }); // Список бесед из снапшота уже знает про звонок — запросов за ним не нужно. expect(await screen.findByTestId('dm-call-c-dm')).toBeInTheDocument(); expect(screen.getByTestId('dm-call-c-dm')).toHaveAttribute('data-status', 'ringing'); expect(useSessionStore.getState().dmChannels[0]?.active_call?.id).toBe('call-7'); }); });