diff --git a/web/e2e/realtime.spec.ts b/web/e2e/realtime.spec.ts index 7966e76..e426c3c 100644 --- a/web/e2e/realtime.spec.ts +++ b/web/e2e/realtime.spec.ts @@ -35,6 +35,8 @@ const BASE_URL = process.env.GLCHAT_URL ?? 'https://gl.mhspx.su'; const API = '/api/v1'; /** Префикс имени тестового сервера: по нему убираются серверы прошлых прогонов. */ const GUILD_PREFIX = 'Realtime E2E '; +/** Пароль отдельного пробного владельца сервера для проверки инвайтов. */ +const INVITE_PROBE_PASSWORD = 'Rt-Invite-Probe-9x'; /** Постоянные пробные аккаунты: A — владелец сервера, B — второй браузер. */ const OWNER_LOGIN = { username: 'rtprobe', password: 'Rt-Probe-Owner-9x' }; const MEMBER_LOGIN = { username: 'rtprobe2', password: 'Rt-Probe-Member-9x' }; @@ -573,23 +575,29 @@ test.describe('realtime: пункты 1–7 (сервер, роли, оформ browser, playwright, }) => { - // Владелец сервера — наблюдатель B: панель приглашений и квота его. - const guestApi = await apiAs(playwright, owner.state); - const ownerApi = await apiAs(playwright, member.state); + // Приглашения ограничены суточной квотой — 10 на пользователя (AGENT.md + // 8.6), поэтому сервер создаёт отдельный свежий владелец: у постоянных + // пробных аккаунтов квота выгорает за серию прогонов. + const inviterApi = await playwright.request.newContext({ baseURL: BASE_URL }); + const guestApi = await apiAs(playwright, member.state); let session: Session | null = null; let fixture: GuildFixture | null = null; try { - fixture = await prepareGuild(guestApi, ownerApi, { observerOwns: true }); - session = await openSession(browser, member); + await ensureProbeWithWait(inviterApi, { + username: `rtinvite${String(Date.now() % 10_000_000)}`, + password: INVITE_PROBE_PASSWORD, + }); + fixture = await prepareGuild(inviterApi, guestApi); + session = await sessionFrom(browser, await inviterApi.storageState()); const page = session.page; await openWorkspace(page, fixture); - // Список приглашений сервера читается ручкой REST: экрана списка в - // web-клиенте нет, панель показывает только созданную ссылку (см. отчёт). + // Список приглашений читается ручкой REST: экрана списка в web-клиенте + // нет, панель показывает только созданную ссылку (см. отчёт). const listInvites = async (): Promise => { const response = await repeat429(() => - ownerApi.get(`${API}/guilds/${fixture?.guildId}/invites`), + inviterApi.get(`${API}/guilds/${fixture?.guildId ?? ''}/invites`), ); expect(response.ok(), `список приглашений: ${await response.text()}`).toBeTruthy(); const payload = (await response.json()) as { invites: { code: string }[] }; @@ -597,26 +605,40 @@ test.describe('realtime: пункты 1–7 (сервер, роли, оформ }; expect(await listInvites(), 'до создания приглашений нет').toHaveLength(0); - // Приглашение создаётся владельцем через панель сервера. + // Квота исчерпана — пункт проверить нечем: пропускаем с явной причиной. + const probe = await repeat429(() => + inviterApi.post(`${API}/guilds/${fixture?.guildId ?? ''}/invites`, { + data: { max_uses: 0, max_age_seconds: 3600 }, + }), + ); + if (probe.status() === 429) { + test.skip(true, 'суточная квота приглашений исчерпана (10/24 ч)'); + } + expect(probe.ok(), `создание приглашения: ${await probe.text()}`).toBeTruthy(); + const restCode = ((await probe.json()) as { invite: { code: string } }).invite.code; + await expect.poll(listInvites, { timeout: 20_000 }).toContain(restCode); + + // Второе приглашение создаётся владельцем через панель сервера. await page.getByTestId('guild-invite').click(); const linkInput = page.getByTestId('guild-invite-link'); await expect(linkInput).toBeVisible({ timeout: 20_000 }); - const code = inviteCodeFromUrl(await linkInput.inputValue()); - await expect.poll(listInvites, { timeout: 20_000 }).toContain(code); + const uiCode = inviteCodeFromUrl(await linkInput.inputValue()); + await expect.poll(listInvites, { timeout: 20_000 }).toContain(uiCode); // Отзыв: код пропадает из списка и больше не пускает новых участников. - const revoked = await repeat429(() => ownerApi.delete(`${API}/invites/${code}`)); + const revoked = await repeat429(() => inviterApi.delete(`${API}/invites/${uiCode}`)); expect(revoked.ok(), `отзыв приглашения: ${await revoked.text()}`).toBeTruthy(); - await expect.poll(listInvites, { timeout: 20_000 }).not.toContain(code); - const left = await repeat429(() => guestApi.post(`${API}/guilds/${fixture.guildId}/leave`)); - expect(left.ok(), `выход из сервера: ${await left.text()}`).toBeTruthy(); - const join = await repeat429(() => guestApi.post(`${API}/invites/${code}`)); + await expect.poll(listInvites, { timeout: 20_000 }).not.toContain(uiCode); + const join = await guestApi.post(`${API}/invites/${uiCode}`); expect(join.status(), 'отозванное приглашение больше не работает').toBe(404); - await expect(linkInput).toHaveValue(new RegExp(code, 'u')); + + // Ссылка, созданная через панель ранее, остаётся рабочей. + await expect(linkInput).toHaveValue(new RegExp(uiCode, 'u')); + await expect.poll(listInvites, { timeout: 20_000 }).toContain(restCode); } finally { await session?.context.close(); - await cleanup(ownerApi, fixture, MEMBER_LOGIN.password); - await ownerApi.dispose(); + await cleanup(inviterApi, fixture, INVITE_PROBE_PASSWORD); + await inviterApi.dispose(); await guestApi.dispose(); } }); @@ -903,6 +925,15 @@ async function openSession(browser: Browser, account: Account): Promise return { context, page }; } +/** sessionFrom открывает браузерный контекст на готовой сессии аккаунта. */ +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 }; +} + /** * joinVoice входит в голосовую комнату: кнопка входа исчезает после * подключения. Подключение к SFU изредка срывается с первого раза, поэтому diff --git a/web/tests/gatewayEvents.test.ts b/web/tests/gatewayEvents.test.ts index 4f9d0df..a9d9f59 100644 --- a/web/tests/gatewayEvents.test.ts +++ b/web/tests/gatewayEvents.test.ts @@ -142,7 +142,7 @@ describe('диспетчер событий шлюза', () => { it('CHANNEL_CREATE и CHANNEL_UPDATE перечитывают комнаты с сервера', async () => { // Права на комнату персональные: тело события их не несёт, поэтому клиент - // перечитывает список, а не подставляет комнату из кадра (AGENDA.md 11.6). + // перечитывает список, а не подставляет комнату из кадра (AGENT.md 11.6). installFetch([ { match: '/api/v1/guilds/g-1/channels',