Files
glchat/web/tests/voiceRoutes.ts
T

193 lines
6.6 KiB
TypeScript
Raw Permalink Normal View History

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 };
}