372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
|
|
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<typeof installFetch>[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');
|
|||
|
|
});
|
|||
|
|
});
|