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 & Pick, ): 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; 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; 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)['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 }; }