194 lines
5.7 KiB
TypeScript
194 lines
5.7 KiB
TypeScript
|
|
import { apiError, json, type FetchRoute } from './helpers';
|
||
|
|
import { fakeVoiceToken } from './voiceRoutes';
|
||
|
|
import type { DmCall, DmCallMedia, DmCallParticipantState } from '@/api/calls';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Маршруты звонков в беседах для тестов: состояние живёт в замыкании, поэтому
|
||
|
|
* ответы согласованы между запросами — как у настоящего сервера (AGENT.md 7.8).
|
||
|
|
*/
|
||
|
|
|
||
|
|
export function makeDmCall(overrides: Partial<DmCall> & Pick<DmCall, 'id' | 'channel_id'>): DmCall {
|
||
|
|
return {
|
||
|
|
initiator_id: 'user-1',
|
||
|
|
status: 'ringing',
|
||
|
|
media: 'audio',
|
||
|
|
created_at: '2026-09-26T10:00:00Z',
|
||
|
|
duration_seconds: 0,
|
||
|
|
participants: [],
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Участник звонка в моке. */
|
||
|
|
export function makeCallParticipant(
|
||
|
|
user_id: string,
|
||
|
|
state: DmCallParticipantState,
|
||
|
|
overrides: Partial<DmCall['participants'][number]> = {},
|
||
|
|
): DmCall['participants'][number] {
|
||
|
|
return {
|
||
|
|
user_id,
|
||
|
|
state,
|
||
|
|
self_mute: false,
|
||
|
|
self_deaf: false,
|
||
|
|
camera: false,
|
||
|
|
screen: false,
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface DmCallRoutes {
|
||
|
|
routes: FetchRoute[];
|
||
|
|
/** Текущий звонок «сервера»: тесты читают его после запросов. */
|
||
|
|
call: () => DmCall | null;
|
||
|
|
/** Что вернул сервер на последний запрос; для проверки тела PATCH. */
|
||
|
|
setCall: (call: DmCall | null) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface DmCallRoutesOptions {
|
||
|
|
channelId?: string;
|
||
|
|
/** Стартовый звонок: null — никто не звонит. */
|
||
|
|
call?: DmCall | null;
|
||
|
|
/** История звонков беседы. */
|
||
|
|
history?: DmCall[];
|
||
|
|
/** Код ошибки на создание звонка (`voice.disabled`, `dm.call_limit`, ...). */
|
||
|
|
startError?: { code: string; status: number };
|
||
|
|
token?: string;
|
||
|
|
url?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function dmCallRoutes(options: DmCallRoutesOptions = {}): DmCallRoutes {
|
||
|
|
const channelId = options.channelId ?? 'c-dm';
|
||
|
|
let call: DmCall | null = options.call ?? null;
|
||
|
|
const history = options.history ?? [];
|
||
|
|
|
||
|
|
const join = (): Response => {
|
||
|
|
if (call === null) {
|
||
|
|
return apiError('dm.call_not_found', 404);
|
||
|
|
}
|
||
|
|
return json({
|
||
|
|
call,
|
||
|
|
token: options.token ?? fakeVoiceToken(),
|
||
|
|
url: options.url ?? 'wss://gl.localhost/rtc',
|
||
|
|
room: `dm_call_${call.id}`,
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const routes: FetchRoute[] = [
|
||
|
|
{
|
||
|
|
match: `/channels/${channelId}/call/accept`,
|
||
|
|
method: 'POST',
|
||
|
|
response: () => {
|
||
|
|
if (call !== null) {
|
||
|
|
call = {
|
||
|
|
...call,
|
||
|
|
status: 'active',
|
||
|
|
started_at: '2026-09-26T10:00:05Z',
|
||
|
|
participants: call.participants.map((participant) =>
|
||
|
|
participant.state === 'invited' ? { ...participant, state: 'joined' } : participant,
|
||
|
|
),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return join();
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
match: `/channels/${channelId}/call/decline`,
|
||
|
|
method: 'POST',
|
||
|
|
response: () => {
|
||
|
|
if (call !== null) {
|
||
|
|
call = {
|
||
|
|
...call,
|
||
|
|
status: 'ended',
|
||
|
|
end_reason: 'declined',
|
||
|
|
ended_at: '2026-09-26T10:00:06Z',
|
||
|
|
participants: call.participants.map((participant) =>
|
||
|
|
participant.state === 'invited' ? { ...participant, state: 'declined' } : participant,
|
||
|
|
),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return json({ call });
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
match: `/channels/${channelId}/call/end`,
|
||
|
|
method: 'POST',
|
||
|
|
response: () => {
|
||
|
|
if (call !== null) {
|
||
|
|
call = {
|
||
|
|
...call,
|
||
|
|
status: 'ended',
|
||
|
|
end_reason: 'completed',
|
||
|
|
ended_at: '2026-09-26T10:01:00Z',
|
||
|
|
participants: call.participants.map((participant) =>
|
||
|
|
participant.state === 'joined' ? { ...participant, state: 'left' } : participant,
|
||
|
|
),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return json({ call });
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
match: `/channels/${channelId}/call/@me`,
|
||
|
|
method: 'PATCH',
|
||
|
|
response: ({ body }) => {
|
||
|
|
const patch = (body ?? {}) as Record<string, boolean>;
|
||
|
|
if (call !== null) {
|
||
|
|
call = {
|
||
|
|
...call,
|
||
|
|
participants: call.participants.map((participant) =>
|
||
|
|
participant.user_id === 'user-1'
|
||
|
|
? {
|
||
|
|
...participant,
|
||
|
|
self_mute: patch['self_mute'] ?? participant.self_mute,
|
||
|
|
self_deaf: patch['self_deaf'] ?? participant.self_deaf,
|
||
|
|
camera: patch['camera'] ?? participant.camera,
|
||
|
|
screen: patch['screen'] ?? participant.screen,
|
||
|
|
}
|
||
|
|
: participant,
|
||
|
|
),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return json({ call });
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
match: `/channels/${channelId}/calls`,
|
||
|
|
method: 'GET',
|
||
|
|
response: () => json({ calls: history }),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
match: `/channels/${channelId}/call`,
|
||
|
|
method: 'GET',
|
||
|
|
response: () => json({ call }),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
match: `/channels/${channelId}/call`,
|
||
|
|
method: 'POST',
|
||
|
|
response: ({ body }) => {
|
||
|
|
if (options.startError !== undefined) {
|
||
|
|
return apiError(options.startError.code, options.startError.status);
|
||
|
|
}
|
||
|
|
const media = ((body ?? {}) as { media?: DmCallMedia }).media ?? 'audio';
|
||
|
|
call = makeDmCall({
|
||
|
|
id: 'call-1',
|
||
|
|
channel_id: channelId,
|
||
|
|
media,
|
||
|
|
participants: [
|
||
|
|
makeCallParticipant('user-1', 'joined'),
|
||
|
|
makeCallParticipant('user-2', 'invited'),
|
||
|
|
],
|
||
|
|
});
|
||
|
|
return join();
|
||
|
|
},
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
return {
|
||
|
|
routes,
|
||
|
|
call: () => call,
|
||
|
|
setCall: (next) => {
|
||
|
|
call = next;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|