diff --git a/web/e2e/voice.spec.ts b/web/e2e/voice.spec.ts index 3adaffc..4740174 100644 --- a/web/e2e/voice.spec.ts +++ b/web/e2e/voice.spec.ts @@ -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 { * не переключают на другого пользователя. */ 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, + attempts = 4, +): Promise>> { + 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 { @@ -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('вход не удался'); } /**