193 lines
6.6 KiB
TypeScript
193 lines
6.6 KiB
TypeScript
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 };
|
||
}
|