test(web): e2e голоса переиспользует тестового пользователя и терпит лимиты
- постоянный аккаунт `voiceprobe` вместо регистрации нового на каждый прогон (API удаления пользователей пока нет — это Фаза 5); - `postWithRetry` и повтор входа в браузере учитывают `rate_limited` и `retry_after_ms`: защита от флуда входа обязана работать, а тест не должен падать из-за неё; - проверено два прогона подряд: оба зелёные, в базе стенда остаются только аккаунт администратора, тестовый пользователь и серверы владельца.
This commit is contained in:
+82
-26
@@ -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);
|
||||
if (options.totp !== undefined) {
|
||||
await page.getByLabel('Код 2FA или резервный код').fill(totp(options.totp));
|
||||
// Лимит частоты входов (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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
await page.getByRole('button', { name: /Войти/u }).click();
|
||||
await expect(page).toHaveURL(/\/app/u, { timeout: 30_000 });
|
||||
return { context, page };
|
||||
throw new Error('вход не удался');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user