From 71c0107b8723d0a6713d3ec7977effd4c8b6338a Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 26 Sep 2026 11:06:36 +0300 Subject: [PATCH] =?UTF-8?q?test(web):=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BC=D0=BE=D1=89=D0=BD=D0=B8=D0=BA=D0=B8=20=D0=B6?= =?UTF-8?q?=D0=B8=D0=B2=D1=8B=D1=85=20e2e=20=D0=B2=20support.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новые спеки (§11.5, личные серверы и шапка сервера) используют тот же набор шагов, что матрица 11.6, но её помощники лежали внутри `realtime.spec.ts`. Вынес в `support.ts`, чтобы не копировать их в третий раз: сессии и лимит входов, повтор при 429, step-up (в том числе с 2FA админа), подготовка сервера и уборка, комнаты, роли, сообщения, звуки, личные беседы, друзья и блокировки. --- web/e2e/support.ts | 469 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 469 insertions(+) diff --git a/web/e2e/support.ts b/web/e2e/support.ts index 8d37b5b..485abd1 100644 --- a/web/e2e/support.ts +++ b/web/e2e/support.ts @@ -3,6 +3,7 @@ import { deflateSync } from 'node:zlib'; import { expect, type APIRequestContext, + type APIResponse, type Browser, type BrowserContext, type Page, @@ -298,3 +299,471 @@ function crc32(buffer: Buffer): number { } return (crc ^ 0xffffffff) >>> 0; } + +/* ------------------------------------------------------------------------- + * Общие помощники живых спеков (§11.5, §11.6): сессии, лимиты частоты, + * подготовка сервера и уборка. Спеки пунктов 1–13 держат свои копии — здесь + * то, чем пользуются спеки инстанс-админа, личных серверов и шапки сервера. + * ---------------------------------------------------------------------- */ + +/** Базовый адрес стенда: тот же, что у Playwright (GLCHAT_URL). */ +export function standUrl(): string { + return process.env.GLCHAT_URL ?? 'https://gl.mhspx.su'; +} + +/** Префикс версионированного API: помощники ходят теми же ручками, что клиент. */ +const API = '/api/v1'; + +/** Сохранённое состояние сессии: передаётся в `apiAs` и в браузер. */ +export type StorageState = Awaited>; + +/** Фабрика REST-контекстов: сюда приходит фикстура `playwright` из теста. */ +export interface Requester { + request: { + newContext(options: { + baseURL: string; + storageState: StorageState; + }): Promise; + }; +} + +/** Логин пробного аккаунта: username раскрывается в `username@gl.mhspx.su`. */ +export interface ProbeLogin { + username: string; + password: string; +} + +/** Аккаунт стенда: профиль, сессия теста и отдельная сессия браузера. */ +export interface Account extends Probe { + displayName: string; + /** Сессия для REST-действий теста. */ + state: StorageState; + /** + * Отдельная сессия для браузера: лимит API — 120 запросов в минуту на + * сессию (AGENT.md 8.6), общая сессия тормозила бы сам интерфейс. + */ + browserState: StorageState; +} + +/** + * repeat429 повторяет запрос при срабатывании лимита частоты: сервер отвечает + * `rate_limited` и `retry_after_ms`, а тест не должен падать из-за защиты, + * которая как раз и обязана работать (AGENT.md 8.6). + */ +export async function repeat429( + request: () => Promise, + attempts = 20, +): Promise { + let response = await request(); + for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) { + const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number }; + // Ждём столько, сколько просит сервер: лимит — 120 запросов в минуту. + const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 1_000), 30_000); + await new Promise((resolve) => setTimeout(resolve, wait + 250)); + response = await request(); + } + return response; +} + +/** + * ensureProbeWithWait входит в пробный аккаунт, пережидая лимит входов: сервер + * пускает 5 попыток в минуту на IP (AGENT.md 8.6), поэтому при 429 ждём окно + * целиком и повторяем, а не падаем всем файлом. + */ +export async function ensureProbeWithWait( + api: APIRequestContext, + login: ProbeLogin, +): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + return await ensureProbe(api, login.username, login.password); + } catch { + await new Promise((resolve) => setTimeout(resolve, 65_000)); + } + } + throw new Error(`не удалось войти в пробный аккаунт ${login.username}`); +} + +/** + * freshState входит заново и отдаёт состояние новой сессии аккаунта: браузер + * получает её отдельно от REST, иначе обе стороны делят один лимит запросов. + */ +export async function freshState(api: APIRequestContext, login: ProbeLogin): Promise { + const email = `${login.username}@gl.mhspx.su`; + let response = await repeat429(() => + api.post(`${API}/auth/login`, { data: { email, password: login.password } }), + ); + if (!response.ok()) { + // Лимит входов (5 в минуту на IP) исчерпан: ждём окно и пробуем ещё раз. + await new Promise((resolve) => setTimeout(resolve, 65_000)); + response = await repeat429(() => + api.post(`${API}/auth/login`, { data: { email, password: login.password } }), + ); + } + expect(response.ok(), `повторный вход для браузера: ${await response.text()}`).toBeTruthy(); + return api.storageState(); +} + +/** + * freshAdminState входит администратором заново и отдаёт состояние новой + * сессии для браузера: вход администратора требует кода 2FA (AGENT.md 7.19). + */ +export async function freshAdminState(api: APIRequestContext): Promise { + const admin = adminCredentials(); + const login = (): Promise => + api.post(`${API}/auth/login`, { + data: { email: admin.email, password: admin.password, totp_code: totp(admin.totp) }, + }); + let response = await repeat429(login); + if (!response.ok()) { + // Лимит входов (5 в минуту на IP) исчерпан: ждём окно и пробуем ещё раз. + await new Promise((resolve) => setTimeout(resolve, 65_000)); + response = await repeat429(login); + } + expect(response.ok(), `вход администратора для браузера: ${await response.text()}`).toBeTruthy(); + return api.storageState(); +} + +/** displayName читает отображаемое имя аккаунта: его показывает интерфейс. */ +export async function displayName(api: APIRequestContext): Promise { + const response = await repeat429(() => api.get(`${API}/users/@me`)); + expect(response.ok(), `текущий пользователь: ${await response.text()}`).toBeTruthy(); + const payload = (await response.json()) as { user: { display_name: string } }; + return payload.user.display_name; +} + +/** apiAs создаёт REST-контекст из сохранённой сессии: вход не тратится. */ +export function apiAs( + playwright: Requester, + state: StorageState, + baseURL = standUrl(), +): Promise { + return playwright.request.newContext({ baseURL, storageState: state }); +} + +/** stepUp подтверждает личность: чувствительные ручки требуют свежий step-up. */ +export async function stepUp(api: APIRequestContext, password: string): Promise { + const response = await repeat429(() => api.post(`${API}/auth/step-up`, { data: { password } })); + expect(response.ok(), `step-up: ${await response.text()}`).toBeTruthy(); +} + +/** + * stepUpAsAdmin подтверждает личность администратора инстанса: у него включена + * обязательная 2FA, поэтому одной парольной проверки серверу мало (AGENT.md 7.19). + */ +export async function stepUpAsAdmin(api: APIRequestContext): Promise { + const admin = adminCredentials(); + const response = await repeat429(() => + api.post(`${API}/auth/step-up`, { + data: { password: admin.password, totp_code: totp(admin.totp) }, + }), + ); + expect(response.ok(), `step-up администратора: ${await response.text()}`).toBeTruthy(); +} + +/** + * openSession открывает браузерный контекст из подготовленной сессии: вход + * выполняется один раз на файл, поэтому прогон не упирается в лимит входов. + */ +export async function openSession(browser: Browser, account: Account): Promise { + return sessionFrom(browser, account.browserState); +} + +/** sessionFrom открывает браузерный контекст на готовой сессии аккаунта. */ +export async function sessionFrom(browser: Browser, state: StorageState): Promise { + const context = await browser.newContext({ storageState: state }); + const page = await context.newPage(); + await page.goto('/app'); + await expect(page.getByTestId('guild-rail')).toBeVisible({ timeout: 30_000 }); + return { context, page }; +} + +/** + * openApp открывает приложение на готовой сессии и ждёт оболочку, а не рейку + * серверов: у аккаунта без серверов рейка пуста и потому невидима. + */ +export async function openApp(browser: Browser, state: StorageState): Promise { + const context = await browser.newContext({ storageState: state }); + const page = await context.newPage(); + await page.goto('/app'); + await expect(page.getByTestId('friends-rail-link')).toBeVisible({ timeout: 30_000 }); + return { context, page }; +} + +/** Открытая браузерная сессия: контекст и страница приложения. */ +export interface Session { + context: BrowserContext; + page: Page; +} + +/** Комната сервера: id, имя и тип — минимум для подготовки и проверок. */ +export interface Channel { + id: string; + name: string; + type: string; + description?: string; +} + +/** Роль сервера: id, имя, права и позиция в иерархии. */ +export interface Role { + id: string; + name: string; + color: number; + position: number; + permissions: string; + is_default: boolean; +} + +/** Сервер, подготовленный для теста: id, имя и первая текстовая комната. */ +export interface GuildFixture { + guildId: string; + guildName: string; + textChannelId: string; + textChannelName: string; + everyoneRoleId: string; +} + +/** + * prepareGuild создаёт сервер от имени владельца и вводит в него наблюдателя: + * вступление идёт через временную публичность, чтобы не тратить суточную квоту + * приглашений (10 на пользователя, AGENT.md 8.6). + */ +export async function prepareGuild( + ownerApi: APIRequestContext, + joinerApi: APIRequestContext, + name: string, +): Promise { + const guild = await createGuild(ownerApi, name); + const channels = await listChannels(ownerApi, guild.id); + const text = channels.find((channel) => channel.type === 'text'); + expect(text, 'у нового сервера есть текстовая комната').toBeTruthy(); + const roles = await listRoles(ownerApi, guild.id); + const everyone = roles.find((role) => role.is_default); + expect(everyone, 'у сервера есть роль по умолчанию').toBeTruthy(); + + const opened = await repeat429(() => + ownerApi.patch(`${API}/guilds/${guild.id}`, { data: { public: true } }), + ); + expect(opened.ok(), `открытый сервер: ${await opened.text()}`).toBeTruthy(); + const joined = await repeat429(() => joinerApi.post(`${API}/guilds/${guild.id}/join`)); + expect(joined.ok(), `вступление в сервер: ${await joined.text()}`).toBeTruthy(); + const closed = await repeat429(() => + ownerApi.patch(`${API}/guilds/${guild.id}`, { data: { public: false } }), + ); + expect(closed.ok(), `закрытие сервера: ${await closed.text()}`).toBeTruthy(); + + return { + guildId: guild.id, + guildName: guild.name, + textChannelId: text?.id ?? '', + textChannelName: text?.name ?? '', + everyoneRoleId: everyone?.id ?? '', + }; +} + +/** cleanupGuild удаляет тестовый сервер: мусор на стенде не остаётся. */ +export async function cleanupGuild( + api: APIRequestContext, + fixture: GuildFixture | null, + password: string, +): Promise { + if (fixture === null) { + return; + } + await stepUp(api, password).catch(() => undefined); + await repeat429(() => api.delete(`${API}/guilds/${fixture.guildId}`)).catch(() => undefined); +} + +/** listChannels читает комнаты сервера. */ +export async function listChannels(api: APIRequestContext, guildId: string): Promise { + const response = await repeat429(() => api.get(`${API}/guilds/${guildId}/channels`)); + expect( + response.ok(), + `комнаты сервера (${String(response.status())}): ${await response.text()}`, + ).toBeTruthy(); + return ((await response.json()) as { channels: Channel[] }).channels; +} + +/** createChannel создаёт комнату и возвращает её описание. */ +export async function createChannel( + api: APIRequestContext, + guildId: string, + body: Record, +): Promise { + const response = await repeat429(() => + api.post(`${API}/guilds/${guildId}/channels`, { data: body }), + ); + expect(response.ok(), `создание комнаты: ${await response.text()}`).toBeTruthy(); + return ((await response.json()) as { channel: Channel }).channel; +} + +/** patchChannel меняет комнату: имя, описание, slowmode, лимит участников. */ +export async function patchChannel( + api: APIRequestContext, + guildId: string, + channelId: string, + body: Record, +): Promise { + const response = await repeat429(() => + api.patch(`${API}/guilds/${guildId}/channels/${channelId}`, { data: body }), + ); + expect(response.ok(), `правка комнаты: ${await response.text()}`).toBeTruthy(); + return ((await response.json()) as { channel: Channel }).channel; +} + +/** listRoles читает роли сервера. */ +export async function listRoles(api: APIRequestContext, guildId: string): Promise { + const response = await repeat429(() => api.get(`${API}/guilds/${guildId}/roles`)); + expect( + response.ok(), + `роли сервера (${String(response.status())}): ${await response.text()}`, + ).toBeTruthy(); + return ((await response.json()) as { roles: Role[] }).roles; +} + +/** createRole создаёт роль сервера: имя, права и позиция в иерархии. */ +export async function createRole( + api: APIRequestContext, + guildId: string, + body: Record, +): Promise { + const response = await repeat429(() => + api.post(`${API}/guilds/${guildId}/roles`, { data: body }), + ); + expect(response.ok(), `создание роли: ${await response.text()}`).toBeTruthy(); + return ((await response.json()) as { role: Role }).role; +} + +/** assignRole выдаёт роль участнику сервера. */ +export async function assignRole( + api: APIRequestContext, + guildId: string, + userId: string, + roleId: string, +): Promise { + const response = await repeat429(() => + api.put(`${API}/guilds/${guildId}/members/${userId}/roles/${roleId}`), + ); + expect(response.ok(), `выдача роли: ${await response.text()}`).toBeTruthy(); +} + +/** uploadSound загружает звук саундборда или звуковой палитры сервера. */ +export async function uploadSound( + api: APIRequestContext, + guildId: string, + name: string, + kind: 'soundboard' | 'ui', +): Promise { + const response = await repeat429(() => + api.post(`${API}/guilds/${guildId}/sounds?kind=${kind}`, { + multipart: { + name, + ...(kind === 'ui' ? { event: 'message' } : {}), + file: { name: 'sound.wav', mimeType: 'audio/wav', buffer: wavBytes() }, + }, + }), + ); + expect(response.ok(), `загрузка звука: ${await response.text()}`).toBeTruthy(); + return ((await response.json()) as { sound: { id: string } }).sound.id; +} + +/** wavBytes собирает корректный WAV с тишиной: сервер проверяет тип файла. */ +export function wavBytes(): Buffer { + const samples = 800; + const data = Buffer.alloc(samples * 2); + const header = Buffer.alloc(44); + header.write('RIFF', 0, 'ascii'); + header.writeUInt32LE(36 + data.length, 4); + header.write('WAVE', 8, 'ascii'); + header.write('fmt ', 12, 'ascii'); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); + header.writeUInt16LE(1, 22); + header.writeUInt32LE(8_000, 24); + header.writeUInt32LE(16_000, 28); + header.writeUInt16LE(2, 32); + header.writeUInt16LE(16, 34); + header.write('data', 36, 'ascii'); + header.writeUInt32LE(data.length, 40); + return Buffer.concat([header, data]); +} + +/** postMessage отправляет сообщение и возвращает его id. */ +export async function postMessage( + api: APIRequestContext, + channelId: string, + content: string, +): Promise { + const response = await repeat429(() => + api.post(`${API}/channels/${channelId}/messages`, { data: { content } }), + ); + expect(response.ok(), `отправка сообщения: ${await response.text()}`).toBeTruthy(); + return ((await response.json()) as { message: { id: string } }).message.id; +} + +/** openDmChannel открывает личную беседу с пользователем и отдаёт её id. */ +export async function openDmChannel(api: APIRequestContext, recipientId: string): Promise { + const response = await repeat429(() => + api.post(`${API}/users/@me/channels`, { data: { recipient_id: recipientId } }), + ); + expect(response.ok(), `личная беседа: ${await response.text()}`).toBeTruthy(); + return ((await response.json()) as { channel: { id: string } }).channel.id; +} + +/** setBlocked ставит или снимает блокировку пользователя (AGENT.md 7.2). */ +export async function setBlocked( + api: APIRequestContext, + userId: string, + blocked: boolean, +): Promise { + return repeat429(() => + blocked + ? api.put(`${API}/users/@me/blocks/${userId}`) + : api.delete(`${API}/users/@me/blocks/${userId}`), + ); +} + +/** removeFriendship снимает связь с пользователем: заявку, дружбу или блокировку. */ +export async function removeFriendship(api: APIRequestContext, userId: string): Promise { + await repeat429(() => api.delete(`${API}/users/@me/relationships/${userId}`)).catch( + () => undefined, + ); +} + +/** + * ensureFriends доводит пару до состояния «друзья»: если связи нет — отправляет + * заявку и принимает её. Повторный вызов на существующей дружбе ничего не делает. + */ +export async function ensureFriends( + requesterApi: APIRequestContext, + accepterApi: APIRequestContext, + requesterId: string, + accepterId: string, +): Promise { + const list = await repeat429(() => requesterApi.get(`${API}/users/@me/relationships`)); + if (list.ok()) { + const payload = (await list.json()) as { friends: { user_id: string }[] }; + if (payload.friends.some((friend) => friend.user_id === accepterId)) { + return; + } + } + const request = await repeat429(() => + requesterApi.post(`${API}/users/@me/relationships`, { data: { user_id: accepterId } }), + ); + expect(request.ok(), `заявка в друзья: ${await request.text()}`).toBeTruthy(); + const accept = await repeat429(() => + accepterApi.post(`${API}/users/@me/relationships/${requesterId}/accept`), + ); + expect(accept.ok(), `принятие заявки: ${await accept.text()}`).toBeTruthy(); +} + +/** openWorkspace открывает сервер и текстовую комнату в браузере. */ +export async function openWorkspace(page: Page, fixture: GuildFixture): Promise { + await openGuild(page, fixture.guildName); + await openChannel(page, fixture.textChannelName); + await expect(page.getByTestId('guild-header')).toContainText(fixture.guildName, { + timeout: 30_000, + }); + // Машина под нагрузкой грузит SPA медленнее: первой отрисовке даём запас. + await expect(page.getByTestId('message-list')).toBeVisible({ timeout: 30_000 }); +}