diff --git a/web/e2e/realtime-phase7.spec.ts b/web/e2e/realtime-phase7.spec.ts new file mode 100644 index 0000000..8c0852e --- /dev/null +++ b/web/e2e/realtime-phase7.spec.ts @@ -0,0 +1,379 @@ +import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; + +import { + apiAs, + createChannel, + createGuild, + createRole, + displayName, + dropStaleGuilds, + ensureProbe, + listChannels, + listRoles, + openApp, + openGuild, + pngBytes, + postWithRetry, + repeat429, + signIn, + stepUp, + trackLoads, + type Account, + type ProbeLogin, + type Session, +} from './support'; + +/** + * Realtime-матрица AGENT.md 11.6 для остатка Фазы 7: иконка и имя групповой + * беседы (D-070, D-075) и личные шаблоны серверов (7.3). Действие A видно + * второму браузеру без F5 и повторного входа — за прогон ни одной полной + * загрузки документа (`trackLoads` = 0). Запуск только по флагу, тест требует + * стенда: + * + * GLCHAT_E2E_REALTIME=1 GLCHAT_URL=https://gl.mhspx.su \ + * PLAYWRIGHT_BROWSERS_PATH=../.cache/ms-playwright \ + * npx playwright test e2e/realtime-phase7.spec.ts + */ +const enabled = process.env.GLCHAT_E2E_REALTIME === '1'; +const API = '/api/v1'; +const BASE_URL = process.env.GLCHAT_URL ?? 'https://gl.mhspx.su'; +/** Префикс тестовых данных: по нему убираются серверы, шаблоны и группы прошлых прогонов. */ +const PREFIX = 'Realtime E2E '; +/** Постоянные пробные аккаунты: A — владелец, B — второй браузер, C — третий участник. */ +const OWNER_LOGIN: ProbeLogin = { username: 'rtprobe', password: 'Rt-Probe-Owner-9x' }; +const MEMBER_LOGIN: ProbeLogin = { username: 'rtprobe2', password: 'Rt-Probe-Member-9x' }; +const THIRD_LOGIN: ProbeLogin = { username: 'rtprobe3', password: 'Rt-Probe-Third-9x' }; + +let owner: Account; +let member: Account; +let third: Account; + +test.describe('realtime: иконка и имя групповой беседы, личные шаблоны (Фаза 7)', () => { + test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_REALTIME=1'); + + // Лимит API — 120 запросов в минуту на сессию (AGENT.md 8.6): пауза между + // тестами даёт ведру лимита наполниться, иначе интерфейс получает 429. + test.afterEach(async () => { + await new Promise((resolve) => setTimeout(resolve, 4_000)); + }); + + test.beforeAll(async ({ playwright }) => { + const ownerApi = await playwright.request.newContext({ baseURL: BASE_URL }); + const memberApi = await playwright.request.newContext({ baseURL: BASE_URL }); + const thirdApi = await playwright.request.newContext({ baseURL: BASE_URL }); + try { + // Лимит входов — 5 в минуту на IP (AGENT.md 8.6): входы разносим по времени. + const ownerProbe = await ensureProbe(ownerApi, OWNER_LOGIN.username, OWNER_LOGIN.password); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + const memberProbe = await ensureProbe( + memberApi, + MEMBER_LOGIN.username, + MEMBER_LOGIN.password, + ); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + const thirdProbe = await ensureProbe(thirdApi, THIRD_LOGIN.username, THIRD_LOGIN.password); + const ownerName = await displayName(ownerApi); + const memberName = await displayName(memberApi); + const thirdName = await displayName(thirdApi); + // Уборка данных прошлых прогонов: удаление серверов требует свежий step-up. + await stepUp(ownerApi, OWNER_LOGIN.password); + await dropStaleGuilds(ownerApi, PREFIX); + await dropStaleTemplates(ownerApi, PREFIX); + await dropStaleGroups(ownerApi, PREFIX, ownerProbe.id); + await stepUp(memberApi, MEMBER_LOGIN.password); + await dropStaleGuilds(memberApi, PREFIX); + // Браузерные сессии берём из тех же api-контекстов: лишние входы не + // тратятся, а наблюдатель входит формой уже внутри теста. + owner = { + ...ownerProbe, + displayName: ownerName, + state: await ownerApi.storageState(), + browserState: await ownerApi.storageState(), + }; + member = { + ...memberProbe, + displayName: memberName, + state: await memberApi.storageState(), + browserState: await memberApi.storageState(), + }; + third = { + ...thirdProbe, + displayName: thirdName, + state: await thirdApi.storageState(), + browserState: await thirdApi.storageState(), + }; + } finally { + await ownerApi.dispose(); + await memberApi.dispose(); + await thirdApi.dispose(); + } + }); + + test('26. Иконка и имя групповой беседы: у наблюдателя без перезагрузки', async ({ + browser, + playwright, + }) => { + const ownerApi = await apiAs(playwright, owner.state); + const sessions: Session[] = []; + let groupId = ''; + + try { + // Группа — это минимум три человека (AGENT.md 7.8): владелец и два гостя. + const created = await postWithRetry(ownerApi, `${API}/users/@me/channels/group`, { + name: `${PREFIX}группа`, + recipient_ids: [member.id, third.id], + }); + expect(created.ok(), `групповая беседа: ${await created.text()}`).toBeTruthy(); + groupId = ((await created.json()) as { channel: { id: string } }).channel.id; + + // Владелец — на подготовленной сессии, наблюдатель входит формой: пути разные. + sessions.push(await openApp(browser, owner.browserState)); + sessions.push(await signIn(browser, { email: member.email, password: member.password })); + const [actor, observer] = sessions as [Session, Session]; + await openGroupConversation(actor.page, groupId); + await openGroupConversation(observer.page, groupId); + + // Считаем полные загрузки документа: F5 в сценарии быть не должно. + const loads = trackLoads(observer.page); + loads.reset(); + + // Владелец ставит иконку беседы через меню в шапке. + await actor.page.getByTestId('dm-owner-menu').click(); + await actor.page.getByTestId('dm-icon-upload').click(); + await actor.page.getByTestId('dm-icon-input').setInputFiles({ + name: 'group-icon.png', + mimeType: 'image/png', + buffer: pngBytes(64, 64, [30, 110, 210]), + }); + const actorHeader = actor.page.getByTestId('dm-group-header'); + await expect(actorHeader.locator('img')).toHaveAttribute('src', /\/files\/\d+/u, { + timeout: 30_000, + }); + + // Наблюдатель: иконка приходит событием DM_CHANNEL_UPDATE в шапку и список. + const observerHeader = observer.page.getByTestId('dm-group-header'); + await expect(observerHeader.locator('img')).toHaveAttribute('src', /\/files\/\d+/u, { + timeout: 20_000, + }); + await expect(observer.page.getByTestId(`dm-link-${groupId}`).locator('img')).toHaveAttribute( + 'src', + /\/files\/\d+/u, + { timeout: 20_000 }, + ); + // Участник-не-владелец меню владельца не получает: права проверяет сервер. + await expect(observer.page.getByTestId('dm-owner-menu')).toHaveCount(0); + + // Переименование: владелец через диалог, наблюдатель видит новое имя. + const renamed = `${PREFIX}кино ${Date.now()}`; + await actor.page.getByTestId('dm-owner-menu').click(); + await actor.page.getByTestId('dm-group-rename').click(); + const dialog = actor.page.getByRole('dialog', { name: 'Переименовать беседу' }); + await expect(dialog).toBeVisible({ timeout: 20_000 }); + // Поле открывается с текущим именем — его и правит владелец. + await expect(dialog.getByLabel('Название группы')).toHaveValue(`${PREFIX}группа`); + await dialog.getByLabel('Название группы').fill(renamed); + await dialog.getByRole('button', { name: 'Сохранить' }).click(); + await expect(dialog).toBeHidden({ timeout: 20_000 }); + await expect(actorHeader).toContainText(renamed, { timeout: 20_000 }); + await expect(observerHeader).toContainText(renamed, { timeout: 20_000 }); + await expect(observer.page.getByTestId(`dm-link-${groupId}`)).toContainText(renamed, { + timeout: 20_000, + }); + + // Имя сохранено на сервере, а не только нарисовано в браузере владельца. + const list = await repeat429(() => ownerApi.get(`${API}/users/@me/channels`)); + expect(list.ok(), `список бесед: ${await list.text()}`).toBeTruthy(); + const channels = ((await list.json()) as { channels: { id: string; name: string }[] }) + .channels; + expect(channels.find((channel) => channel.id === groupId)?.name).toBe(renamed); + + // Ни один шаг матрицы не потребовал перезагрузки страницы. + expect(loads.count(), 'наблюдатель обновлялся без полной загрузки документа').toBe(0); + } finally { + for (const session of sessions) { + await session.context.close(); + } + if (groupId !== '') { + await dropGroup(ownerApi, { id: groupId, member_ids: [member.id, third.id] }, owner.id); + } + await ownerApi.dispose(); + } + }); + + test('27. Личные шаблоны: сервер по шаблону на другом устройстве без перезагрузки', async ({ + browser, + playwright, + }) => { + const ownerApi = await apiAs(playwright, owner.state); + const sessions: Session[] = []; + const stamp = Date.now(); + const sourceName = `${PREFIX}источник ${stamp}`; + const templateName = `${PREFIX}шаблон ${stamp}`; + const roomName = `tpl-room-${stamp}`; + const roleName = `tpl-role-${stamp}`; + const createdName = `${PREFIX}по шаблону ${stamp}`; + let sourceGuildId = ''; + let createdGuildId = ''; + let templateId = ''; + + try { + // Исходный сервер с отличимой структурой: по ней видно, что новый сервер + // собран именно из личного шаблона. + const source = await createGuild(ownerApi, sourceName); + sourceGuildId = source.id; + await createChannel(ownerApi, sourceGuildId, { name: roomName, type: 'text' }); + await createRole(ownerApi, sourceGuildId, { + name: roleName, + permissions: 'VIEW_CHANNEL|SEND_MESSAGES', + }); + + // Первое устройство: сохраняем структуру личным шаблоном в настройках. + const device1 = await openApp(browser, owner.browserState); + sessions.push(device1); + await openGuild(device1.page, sourceName); + await device1.page.getByLabel('Настройки сервера').click(); + const form = device1.page.getByTestId('guild-templates-form'); + await expect(form).toBeVisible({ timeout: 30_000 }); + await form.getByLabel('Название шаблона').fill(templateName); + await form.getByRole('button', { name: 'Сохранить шаблон' }).click(); + await expect(device1.page.getByText('Шаблон сохранён.')).toBeVisible({ timeout: 20_000 }); + + // id шаблона берём по REST: им же наблюдатель выберет его в окне создания. + const mine = await repeat429(() => ownerApi.get(`${API}/guild-templates/mine`)); + expect(mine.ok(), `свои шаблоны: ${await mine.text()}`).toBeTruthy(); + const templates = ((await mine.json()) as { templates: { id: string; name: string }[] }) + .templates; + templateId = templates.find((template) => template.name === templateName)?.id ?? ''; + expect(templateId, 'шаблон сохранён на сервере').not.toBe(''); + + // Второе устройство того же пользователя: свой вход формой. + const device2 = await signIn(browser, { email: owner.email, password: owner.password }); + sessions.push(device2); + const loads = trackLoads(device2.page); + loads.reset(); + + // Свои шаблоны видны в окне создания сервера: список читается при + // открытии, страницу перезагружать не нужно (у личных шаблонов нет + // отдельного события шлюза — они приватны владельцу). + await device2.page.getByLabel('Создать сервер').click(); + const createDialog = device2.page.getByRole('dialog', { name: 'Создать сервер' }); + const option = createDialog.getByTestId(`guild-template-personal-${templateId}`); + await expect(option).toBeVisible({ timeout: 20_000 }); + await option.getByRole('radio').click(); + await createDialog.getByLabel('Название сервера').fill(createdName); + await createDialog.getByRole('button', { name: 'Создать' }).click(); + await expect(device2.page.getByTestId('guild-header')).toContainText(createdName, { + timeout: 30_000, + }); + createdGuildId = device2.page.url().split('/app/')[1]?.split('/')[0] ?? ''; + expect(createdGuildId, 'новый сервер открылся в приложении').not.toBe(''); + + // Структура нового сервера совпала с шаблоном: комната и роль на месте. + const createdChannels = await listChannels(ownerApi, createdGuildId); + expect(createdChannels.map((channel) => channel.name)).toContain(roomName); + const createdRoles = await listRoles(ownerApi, createdGuildId); + expect(createdRoles.map((role) => role.name)).toContain(roleName); + + expect(loads.count(), 'окно создания сервера открылось без полной загрузки документа').toBe( + 0, + ); + } finally { + for (const session of sessions) { + await session.context.close(); + } + await stepUp(ownerApi, OWNER_LOGIN.password).catch(() => undefined); + for (const guildId of [createdGuildId, sourceGuildId]) { + if (guildId !== '') { + await repeat429(() => ownerApi.delete(`${API}/guilds/${guildId}`)).catch(() => undefined); + } + } + if (templateId !== '') { + await repeat429(() => ownerApi.delete(`${API}/guild-templates/${templateId}`)).catch( + () => undefined, + ); + } + await ownerApi.dispose(); + } + }); +}); + +/** Открывает раздел «Друзья» и переходит в беседу по ссылке сайдбара. */ +async function openGroupConversation(page: Page, channelId: string): Promise { + await page.getByRole('link', { name: 'Друзья' }).click(); + const link = page.getByTestId(`dm-link-${channelId}`); + await expect(link).toBeVisible({ timeout: 30_000 }); + await link.click(); + await expect(page.getByTestId('dm-group-header')).toBeVisible({ timeout: 30_000 }); +} + +/** Беседа в списке `/users/@me/channels`: для уборки нужны её участники. */ +interface DmChannelRef { + id: string; + name?: string; + is_group?: boolean; + member_ids?: string[]; + recipients?: { user_id: string; is_self?: boolean }[]; +} + +/** + * dropGroup убирает тестовую группу: иконку снимаем до роспуска (иначе файл + * останется сиротой), участников удаляет владелец, а беседа без участников + * удаляется на сервере целиком. + */ +async function dropGroup( + api: APIRequestContext, + channel: DmChannelRef, + ownerId: string, +): Promise { + await repeat429(() => api.delete(`${API}/channels/${channel.id}/icon`)).catch(() => undefined); + const others = + channel.member_ids ?? + (channel.recipients ?? []) + .map((recipient) => recipient.user_id) + .filter((userId) => userId !== ownerId); + for (const userId of others) { + if (userId === ownerId) { + continue; + } + await repeat429(() => api.delete(`${API}/channels/${channel.id}/recipients/${userId}`)).catch( + () => undefined, + ); + } + await repeat429(() => api.delete(`${API}/channels/${channel.id}/recipients/${ownerId}`)).catch( + () => undefined, + ); +} + +/** dropStaleGroups убирает группы прошлых прогонов: их имена начинаются с префикса. */ +async function dropStaleGroups( + api: APIRequestContext, + prefix: string, + ownerId: string, +): Promise { + const response = await repeat429(() => api.get(`${API}/users/@me/channels`)); + if (!response.ok()) { + return; + } + const { channels } = (await response.json()) as { channels: DmChannelRef[] }; + for (const channel of channels) { + if (channel.is_group === true && (channel.name ?? '').startsWith(prefix)) { + await dropGroup(api, channel, ownerId); + } + } +} + +/** dropStaleTemplates убирает личные шаблоны прошлых прогонов (лимит — 20). */ +async function dropStaleTemplates(api: APIRequestContext, prefix: string): Promise { + const response = await repeat429(() => api.get(`${API}/guild-templates/mine`)); + if (!response.ok()) { + return; + } + const { templates } = (await response.json()) as { templates: { id: string; name: string }[] }; + for (const template of templates) { + if (template.name.startsWith(prefix)) { + await repeat429(() => api.delete(`${API}/guild-templates/${template.id}`)).catch( + () => undefined, + ); + } + } +}