test(web): e2e голоса переиспользует тестового пользователя и терпит лимиты

- постоянный аккаунт `voiceprobe` вместо регистрации нового на каждый прогон
  (API удаления пользователей пока нет — это Фаза 5);
- `postWithRetry` и повтор входа в браузере учитывают `rate_limited` и
  `retry_after_ms`: защита от флуда входа обязана работать, а тест не должен
  падать из-за неё;
- проверено два прогона подряд: оба зелёные, в базе стенда остаются только
  аккаунт администратора, тестовый пользователь и серверы владельца.
This commit is contained in:
2026-09-20 20:07:10 +03:00
parent 4ba449670a
commit 4a0b70ab24
+78 -22
View File
@@ -22,6 +22,8 @@ const enabled = process.env.GLCHAT_E2E_VOICE === '1';
const ADMIN_EMAIL = process.env.GLCHAT_ADMIN_EMAIL ?? '';
const ADMIN_PASSWORD = process.env.GLCHAT_ADMIN_PASSWORD ?? '';
const ADMIN_TOTP = process.env.GLCHAT_ADMIN_TOTP ?? '';
/** Постоянный тестовый пользователь: создаётся один раз и переиспользуется. */
const PROBE_LOGIN = 'voiceprobe';
/** totp генерирует шестизначный код из секрета (RFC 6238, SHA-1, 30 секунд). */
function totp(secret: string, now = Date.now()): string {
@@ -106,8 +108,10 @@ async function finishOnboarding(api: APIRequestContext): Promise<void> {
* не переключают на другого пользователя.
*/
async function loginAsAdmin(api: APIRequestContext): Promise<{ id: string }> {
const response = await api.post('/api/v1/auth/login', {
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD, totp_code: totp(ADMIN_TOTP) },
const response = await postWithRetry(api, '/api/v1/auth/login', {
email: ADMIN_EMAIL,
password: ADMIN_PASSWORD,
totp_code: totp(ADMIN_TOTP),
});
expect(response.ok(), `вход администратора: ${await response.text()}`).toBeTruthy();
const payload = (await response.json()) as { user: { id: string } };
@@ -115,8 +119,61 @@ async function loginAsAdmin(api: APIRequestContext): Promise<{ id: string }> {
}
/**
* prepare создаёт сервер, голосовую комнату и второго пользователя по
* приглашению. `admin` остаётся сессией администратора (модерация и уборка),
* postWithRetry повторяет запрос, если сработал лимит частоты: сервер отвечает
* `rate_limited` и `retry_after_ms`, а тест не должен падать из-за защиты,
* которая как раз и обязана работать (AGENT.md 9.4).
*/
async function postWithRetry(
api: APIRequestContext,
path: string,
data: Record<string, unknown>,
attempts = 4,
): Promise<Awaited<ReturnType<APIRequestContext['post']>>> {
let response = await api.post(path, { data });
for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) {
const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number };
const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 500), 15_000);
await new Promise((resolve) => setTimeout(resolve, wait + 200));
response = await api.post(path, { data });
}
return response;
}
/**
* ensureProbeGuest логинит постоянного тестового пользователя, а если его ещё
* нет — регистрирует. Один и тот же аккаунт переиспользуется между прогонами:
* иначе каждый запуск оставлял бы в базе нового пользователя, а удалять
* пользователей API пока не умеет (это задача админ-панели Фазы 5).
*/
async function ensureProbeGuest(
guest: APIRequestContext,
): Promise<{ id: string; email: string; password: string }> {
const email = `${PROBE_LOGIN}@gl.mhspx.su`;
const password = 'Voice-Probe-Password-9x';
const login = await postWithRetry(guest, '/api/v1/auth/login', { email, password });
if (login.ok()) {
const payload = (await login.json()) as { user: { id: string } };
await finishOnboarding(guest);
return { id: payload.user.id, email, password };
}
const registered = await postWithRetry(guest, '/api/v1/auth/register', {
username: PROBE_LOGIN,
email,
password,
});
expect(
registered.ok(),
`тестовый пользователь ${PROBE_LOGIN} не найден и не зарегистрирован ` +
`(регистрация выключена?): ${await registered.text()}`,
).toBeTruthy();
const payload = (await registered.json()) as { user: { id: string } };
await finishOnboarding(guest);
return { id: payload.user.id, email, password };
}
/**
* prepare создаёт сервер и голосовую комнату, приглашает постоянного тестового
* пользователя. `admin` остаётся сессией администратора (модерация и уборка),
* `guest` — сессией приглашённого пользователя.
*/
async function prepare(admin: APIRequestContext, guest: APIRequestContext): Promise<Fixture> {
@@ -142,20 +199,7 @@ async function prepare(admin: APIRequestContext, guest: APIRequestContext): Prom
expect(invite.ok(), `создание приглашения: ${await invite.text()}`).toBeTruthy();
const code = ((await invite.json()) as { invite: { code: string } }).invite.code;
const guestLogin = `voice${stamp}`;
const guestEmail = `${guestLogin}@gl.mhspx.su`;
const guestPassword = 'Voice-Probe-Password-9x';
const guestContext = await guest.post('/api/v1/auth/register', {
data: {
username: guestLogin,
email: guestEmail,
password: guestPassword,
},
});
expect(guestContext.ok(), `регистрация гостя: ${await guestContext.text()}`).toBeTruthy();
const guestId = ((await guestContext.json()) as { user: { id: string } }).user.id;
await finishOnboarding(guest);
const probe = await ensureProbeGuest(guest);
const accept = await guest.post(`/api/v1/invites/${code}`);
expect(accept.ok(), `принятие приглашения: ${await accept.text()}`).toBeTruthy();
@@ -163,10 +207,10 @@ async function prepare(admin: APIRequestContext, guest: APIRequestContext): Prom
guildId,
guildName,
channelId,
guestEmail,
guestPassword,
guestEmail: probe.email,
guestPassword: probe.password,
adminId: adminUser.id,
guestId,
guestId: probe.id,
};
}
@@ -217,12 +261,24 @@ async function signIn(
await page.goto('/login');
await page.getByLabel('Почта', { exact: true }).fill(options.email);
await page.getByLabel('Пароль', { exact: true }).fill(options.password);
// Лимит частоты входов (5/мин) может сработать на серии прогонов: тогда
// форма показывает ошибку — ждём и пробуем снова со свежим кодом 2FA.
for (let attempt = 1; attempt <= 3; attempt += 1) {
if (options.totp !== undefined) {
await page.getByLabel('Код 2FA или резервный код').fill(totp(options.totp));
}
await page.getByRole('button', { name: /Войти/u }).click();
await expect(page).toHaveURL(/\/app/u, { timeout: 30_000 });
try {
await expect(page).toHaveURL(/\/app/u, { timeout: 20_000 });
return { context, page };
} catch (error) {
if (attempt === 3) {
throw error;
}
await page.waitForTimeout(6_000);
}
}
throw new Error('вход не удался');
}
/**