test(web): общие помощники живых e2e в support.ts
Новые спеки (§11.5, личные серверы и шапка сервера) используют тот же набор шагов, что матрица 11.6, но её помощники лежали внутри `realtime.spec.ts`. Вынес в `support.ts`, чтобы не копировать их в третий раз: сессии и лимит входов, повтор при 429, step-up (в том числе с 2FA админа), подготовка сервера и уборка, комнаты, роли, сообщения, звуки, личные беседы, друзья и блокировки.
This commit is contained in:
@@ -3,6 +3,7 @@ import { deflateSync } from 'node:zlib';
|
|||||||
import {
|
import {
|
||||||
expect,
|
expect,
|
||||||
type APIRequestContext,
|
type APIRequestContext,
|
||||||
|
type APIResponse,
|
||||||
type Browser,
|
type Browser,
|
||||||
type BrowserContext,
|
type BrowserContext,
|
||||||
type Page,
|
type Page,
|
||||||
@@ -298,3 +299,471 @@ function crc32(buffer: Buffer): number {
|
|||||||
}
|
}
|
||||||
return (crc ^ 0xffffffff) >>> 0;
|
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<ReturnType<APIRequestContext['storageState']>>;
|
||||||
|
|
||||||
|
/** Фабрика REST-контекстов: сюда приходит фикстура `playwright` из теста. */
|
||||||
|
export interface Requester {
|
||||||
|
request: {
|
||||||
|
newContext(options: {
|
||||||
|
baseURL: string;
|
||||||
|
storageState: StorageState;
|
||||||
|
}): Promise<APIRequestContext>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Логин пробного аккаунта: 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<APIResponse>,
|
||||||
|
attempts = 20,
|
||||||
|
): Promise<APIResponse> {
|
||||||
|
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<Probe> {
|
||||||
|
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<StorageState> {
|
||||||
|
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<StorageState> {
|
||||||
|
const admin = adminCredentials();
|
||||||
|
const login = (): Promise<APIResponse> =>
|
||||||
|
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<string> {
|
||||||
|
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<APIRequestContext> {
|
||||||
|
return playwright.request.newContext({ baseURL, storageState: state });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** stepUp подтверждает личность: чувствительные ручки требуют свежий step-up. */
|
||||||
|
export async function stepUp(api: APIRequestContext, password: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<Session> {
|
||||||
|
return sessionFrom(browser, account.browserState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sessionFrom открывает браузерный контекст на готовой сессии аккаунта. */
|
||||||
|
export async function sessionFrom(browser: Browser, state: StorageState): Promise<Session> {
|
||||||
|
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<Session> {
|
||||||
|
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<GuildFixture> {
|
||||||
|
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<void> {
|
||||||
|
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<Channel[]> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<Channel> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<Channel> {
|
||||||
|
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<Role[]> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<Role> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<APIResponse> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user