From 8c2bb3e18cdddf3fef7cf7ceec0724130016c4d6 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 26 Sep 2026 11:06:46 +0300 Subject: [PATCH] =?UTF-8?q?test(web):=20e2e=20=D0=BB=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BE=D0=B2?= =?UTF-8?q?,=20DM,=20=D0=B4=D1=80=D1=83=D0=B7=D0=B5=D0=B9=20=D0=B8=20?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сценариев личных серверов и личных бесед в матрице 11.6 не было (AGENT.md 7.2, 7.8, 7.9). Новый спек идёт под тем же флагом `GLCHAT_E2E_REALTIME=1` и тем же стилем: два браузера, `support.ts`, `trackLoads`, уборка в `finally`. - 21: личный (непубличный) сервер — вход по ссылке-приглашению в браузере, сервера нет в каталоге, вход без приглашения 403, вошедший видит историю; - 22: DM 1:1 — непрочитанное в списке бесед, история, «печатает», ответ из интерфейса, правка и удаление без перезагрузки; - 23: заявка в друзья и её принятие кнопкой в интерфейсе; - 24: блокировка из списка друзей — писать нельзя в обе стороны (`dm.blocked`), беседа не открывается, разблокировка возвращает переписку. Приглашение (1 на прогон) создаёт владелец: суточная квота — 10 на пользователя (AGENT.md 8.6). --- web/e2e/personal-dm.spec.ts | 415 ++++++++++++++++++++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 web/e2e/personal-dm.spec.ts diff --git a/web/e2e/personal-dm.spec.ts b/web/e2e/personal-dm.spec.ts new file mode 100644 index 0000000..f54ac4b --- /dev/null +++ b/web/e2e/personal-dm.spec.ts @@ -0,0 +1,415 @@ +import { + expect, + test, + type APIRequestContext, + type APIResponse, + type Page, +} from '@playwright/test'; + +import { + apiAs, + cleanupGuild, + createGuild, + createInvite, + displayName, + dropStaleGuilds, + ensureFriends, + ensureProbeWithWait, + freshState, + openApp, + openChannel, + openDmChannel, + postMessage, + removeFriendship, + repeat429, + setBlocked, + stepUp, + trackLoads, + type Account, + type GuildFixture, + type ProbeLogin, + type Session, +} from './support'; + +/** + * Личные серверы, личные беседы и друзья (AGENT.md 7.2, 7.8, 7.9): сценарии, + * которых не было в realtime-матрице 11.6. Два браузера (A — владелец и + * инициатор, B — второй участник), действия идут и по REST, и через интерфейс. + * Запуск только по флагу, тест требует стенда: + * + * GLCHAT_E2E_REALTIME=1 GLCHAT_URL=https://gl.mhspx.su \ + * PLAYWRIGHT_BROWSERS_PATH=../.cache/ms-playwright \ + * npx playwright test e2e/personal-dm.spec.ts + */ +const enabled = process.env.GLCHAT_E2E_REALTIME === '1'; +const API = '/api/v1'; +/** Префикс имени тестового сервера: по нему убираются серверы прошлых прогонов. */ +const GUILD_PREFIX = 'Personal E2E '; +/** Постоянные пробные аккаунты: A — инициатор, B — второй браузер. */ +const OWNER_LOGIN: ProbeLogin = { username: 'rtprobe', password: 'Rt-Probe-Owner-9x' }; +const MEMBER_LOGIN: ProbeLogin = { username: 'rtprobe2', password: 'Rt-Probe-Member-9x' }; + +let owner: Account; +let member: Account; + +test.describe('личные серверы, DM и друзья (§7.2, §7.8, §7.9)', () => { + 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: standBase() }); + const memberApi = await playwright.request.newContext({ baseURL: standBase() }); + try { + // Лимит входов — 5 в минуту на IP (AGENT.md 8.6): входы разносим по времени. + const ownerProbe = await ensureProbeWithWait(ownerApi, OWNER_LOGIN); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + const memberProbe = await ensureProbeWithWait(memberApi, MEMBER_LOGIN); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + const ownerName = await displayName(ownerApi); + const memberName = await displayName(memberApi); + // Уборка серверов прошлых прогонов: удаление требует свежий step-up. + await stepUp(ownerApi, OWNER_LOGIN.password); + await dropStaleGuilds(ownerApi, GUILD_PREFIX); + await stepUp(memberApi, MEMBER_LOGIN.password); + await dropStaleGuilds(memberApi, GUILD_PREFIX); + owner = { + ...ownerProbe, + displayName: ownerName, + state: await ownerApi.storageState(), + browserState: await freshState(ownerApi, OWNER_LOGIN), + }; + await new Promise((resolve) => setTimeout(resolve, 2_000)); + member = { + ...memberProbe, + displayName: memberName, + state: await memberApi.storageState(), + browserState: await freshState(memberApi, MEMBER_LOGIN), + }; + } finally { + await ownerApi.dispose(); + await memberApi.dispose(); + } + }); + + test('21. Личный сервер: вход по инвайту и закрытость без приглашения', async ({ + browser, + playwright, + }) => { + const ownerApi = await apiAs(playwright, owner.state); + const memberApi = await apiAs(playwright, member.state); + let session: Session | null = null; + let guildId = ''; + + try { + const guild = await createGuild(ownerApi, `${GUILD_PREFIX}${Date.now()}`); + guildId = guild.id; + // Сообщение появляется в комнате ДО приглашения: вошедший по ссылке + // должен увидеть историю, а не гонку «лента загрузилась раньше отправки». + const channels = await repeat429(() => ownerApi.get(`${API}/guilds/${guildId}/channels`)); + const textChannel = ( + (await channels.json()) as { channels: { id: string; name: string; type: string }[] } + ).channels.find((channel) => channel.type === 'text'); + expect(textChannel, 'у сервера есть общая комната').toBeTruthy(); + const text = `привет по инвайту ${Date.now()}`; + await postMessage(ownerApi, textChannel?.id ?? '', text); + + // Приглашение создаёт владелец: у него есть CREATE_INVITES, а квота — + // 10 в сутки на пользователя (AGENT.md 8.6). + const invite = await createInvite(ownerApi, guildId); + + // Сервер непубличный: в каталоге его нет, а вход без приглашения закрыт. + const catalog = await repeat429(() => memberApi.get(`${API}/guilds/public`)); + const names = ((await catalog.json()) as { guilds: { name: string }[] }).guilds.map( + (item) => item.name, + ); + expect(names, 'личный сервер не попадает в каталог публичных').not.toContain(guild.name); + const joinWithoutInvite = await repeat429(() => + memberApi.post(`${API}/guilds/${guildId}/join`), + ); + expect(joinWithoutInvite.status(), 'без приглашения в личный сервер не войти').toBe(403); + + // Вход по ссылке-приглашению в браузере: карточка сервера и кнопка входа. + session = await openApp(browser, member.browserState); + const page = session.page; + await page.goto(`/invite/${invite}`); + await expect(page.getByTestId('invite-guild-name')).toContainText(guild.name, { + timeout: 30_000, + }); + await page.getByTestId('invite-accept').click(); + await expect(page).toHaveURL(new RegExp(`/app/${guildId}`, 'u'), { timeout: 30_000 }); + await expect(page.getByTestId('guild-header')).toContainText(guild.name, { timeout: 30_000 }); + + // Вошедший по инвайту видит комнату и её историю до входа. + await openChannel(page, textChannel?.name ?? ''); + await expect(page.getByTestId('message-list')).toContainText(text, { timeout: 30_000 }); + expect(await memberGuildNames(memberApi), 'сервер появился в списке участника').toContain( + guild.name, + ); + } finally { + await session?.context.close(); + await cleanupGuild(ownerApi, fixtureOf(guildId), OWNER_LOGIN.password); + await ownerApi.dispose(); + await memberApi.dispose(); + } + }); + + test('22. DM 1:1: отправка, правка, удаление, непрочитанные и «печатает»', async ({ + browser, + playwright, + }) => { + const ownerApi = await apiAs(playwright, owner.state); + const session = await openApp(browser, member.browserState); + let dmId = ''; + + try { + const page = session.page; + // Беседу открывает инициатор: у B она появляется событием DM_CHANNEL_CREATE. + dmId = await openDmChannel(ownerApi, member.id); + await openFriends(page); + const link = page.getByTestId(`dm-link-${dmId}`); + await expect(link).toBeVisible({ timeout: 30_000 }); + const loads = trackLoads(page); + loads.reset(); + + // Непрочитанное: сообщение владельца подсвечивает беседу в списке. + const first = `личное ${Date.now()}`; + const firstId = await postMessage(ownerApi, dmId, first); + await expect(page.getByTestId(`dm-unread-${dmId}`)).toBeVisible({ timeout: 20_000 }); + + // Открытие беседы: история видна, счётчик непрочитанных гаснет. + await link.click(); + await expect(page.getByTestId('message-list')).toContainText(first, { timeout: 20_000 }); + await expect(page.getByTestId(`dm-unread-${dmId}`)).toHaveCount(0, { timeout: 20_000 }); + + // «Печатает»: набор текста владельцем показывает индикатор у B. + const typing = await repeat429(() => ownerApi.post(`${API}/channels/${dmId}/typing`)); + expect(typing.ok(), `typing: ${await typing.text()}`).toBeTruthy(); + await expect(page.getByTestId('typing-indicator')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId('typing-indicator')).toContainText(owner.displayName, { + timeout: 20_000, + }); + + // Ответ участника из интерфейса: его видит инициатор. + const reply = `ответ ${Date.now()}`; + const composer = page.getByTestId('composer').locator('textarea').first(); + await composer.fill(reply); + await composer.press('Enter'); + await expect(page.getByTestId('message-list')).toContainText(reply, { timeout: 20_000 }); + await expect + .poll(async () => JSON.stringify(await channelMessages(ownerApi, dmId)), { + timeout: 20_000, + }) + .toContain(reply); + + // Правка владельцем: B видит новый текст и пометку «(изменено)». + const edited = await repeat429(() => + ownerApi.patch(`${API}/channels/${dmId}/messages/${firstId}`, { + data: { content: `${first} (правка)` }, + }), + ); + expect(edited.ok(), `правка в личной беседе: ${await edited.text()}`).toBeTruthy(); + const article = page.locator(`article[data-testid="message-${firstId}"]`); + await expect(article).toContainText('(правка)', { timeout: 20_000 }); + await expect(article).toContainText('(изменено)', { timeout: 20_000 }); + + // Удаление: сообщение пропадает из ленты без перезагрузки. + const removed = await repeat429(() => + ownerApi.delete(`${API}/channels/${dmId}/messages/${firstId}`), + ); + expect(removed.ok(), `удаление в личной беседе: ${await removed.text()}`).toBeTruthy(); + await expect(article).toHaveCount(0, { timeout: 20_000 }); + expect(loads.count(), 'личная беседа обновлялась без полной загрузки документа').toBe(0); + } finally { + await session.context.close(); + await ownerApi.dispose(); + } + }); + + test('23. Друзья: заявка и принятие через интерфейс', async ({ browser, playwright }) => { + const ownerApi = await apiAs(playwright, owner.state); + const memberApi = await apiAs(playwright, member.state); + const session = await openApp(browser, member.browserState); + + try { + const page = session.page; + // Начинаем с нуля: дружба могла остаться от прошлых прогонов. + await removeFriendship(ownerApi, member.id); + await removeFriendship(memberApi, owner.id); + + const request = await repeat429(() => + ownerApi.post(`${API}/users/@me/relationships`, { data: { user_id: member.id } }), + ); + expect(request.ok(), `заявка в друзья: ${await request.text()}`).toBeTruthy(); + + // B видит входящую заявку и принимает её кнопкой — без перезагрузки. + await openFriends(page); + const loads = trackLoads(page); + loads.reset(); + const incoming = page.getByTestId('friends-incoming'); + await expect(incoming).toContainText(owner.displayName, { timeout: 20_000 }); + await incoming.getByRole('button', { name: 'Принять' }).first().click(); + + // После принятия владелец в списке друзей, заявка исчезает. + await expect.poll(async () => friendIds(ownerApi), { timeout: 20_000 }).toContain(member.id); + await expect(incoming).toHaveCount(0, { timeout: 20_000 }); + await ensureFriendsListOpen(page); + await expect(page.getByTestId(`friend-row-${owner.id}`)).toBeVisible({ timeout: 20_000 }); + expect(loads.count(), 'список друзей обновлялся без полной загрузки документа').toBe(0); + } finally { + // Дружба восстанавливается: на ней стоят проверки presence в матрице 11.6. + await ensureFriends(ownerApi, memberApi, owner.id, member.id).catch(() => undefined); + await session.context.close(); + await ownerApi.dispose(); + await memberApi.dispose(); + } + }); + + test('24. Блокировка: писать нельзя в обе стороны, разблокировка возвращает переписку', async ({ + browser, + playwright, + }) => { + const ownerApi = await apiAs(playwright, owner.state); + const memberApi = await apiAs(playwright, member.state); + const session = await openApp(browser, member.browserState); + + try { + const page = session.page; + // Блокировка доступна из списка друзей: сначала дружба, потом меню «…». + await ensureFriends(ownerApi, memberApi, owner.id, member.id); + const dmId = await openDmChannel(ownerApi, member.id); + await openFriends(page); + await ensureFriendsListOpen(page); + const row = page.getByTestId(`friend-row-${owner.id}`); + await expect(row).toBeVisible({ timeout: 20_000 }); + await row.getByRole('button', { name: `Действия: ${owner.displayName}` }).click(); + await page.getByTestId(`friend-block-${owner.id}`).click(); + await expect(page.getByTestId('friends-blocked')).toContainText(owner.displayName, { + timeout: 20_000, + }); + + // Блокировка запрещает переписку в обе стороны (AGENT.md 7.2). + const fromOwner = await ownerApi.post(`${API}/channels/${dmId}/messages`, { + data: { content: 'после блокировки' }, + }); + expect(fromOwner.status(), 'заблокированный не пишет инициатору').toBe(403); + expect(await errorCode(fromOwner)).toBe('dm.blocked'); + const fromMember = await memberApi.post(`${API}/channels/${dmId}/messages`, { + data: { content: 'ответ после блокировки' }, + }); + expect(fromMember.status(), 'инициатор не пишет заблокированному').toBe(403); + expect(await errorCode(fromMember)).toBe('dm.blocked'); + const newDm = await repeat429(() => + ownerApi.post(`${API}/users/@me/channels`, { data: { recipient_id: member.id } }), + ); + expect(newDm.status(), 'беседа с заблокированным не открывается').toBe(403); + + // Разблокировка из интерфейса возвращает переписку. + const blocked = page.getByTestId('friends-blocked'); + await blocked.getByRole('button', { name: 'Разблокировать' }).first().click(); + await expect + .poll(async () => blockedUserIds(ownerApi), { timeout: 20_000 }) + .not.toContain(member.id); + const restored = await ownerApi.post(`${API}/channels/${dmId}/messages`, { + data: { content: 'после разблокировки' }, + }); + expect(restored.ok(), `переписка вернулась: ${await restored.text()}`).toBeTruthy(); + } finally { + // Страховка: блокировка и разрыв дружбы не должны остаться на стенде. + await setBlocked(memberApi, owner.id, false).catch(() => undefined); + await ensureFriends(ownerApi, memberApi, owner.id, member.id).catch(() => undefined); + await session.context.close(); + await ownerApi.dispose(); + await memberApi.dispose(); + } + }); +}); + +/** standBase — адрес стенда: тот же, что у Playwright (GLCHAT_URL). */ +function standBase(): string { + return process.env.GLCHAT_URL ?? 'https://gl.mhspx.su'; +} + +/** fixtureOf собирает минимальное описание сервера для уборки по id. */ +function fixtureOf(guildId: string): GuildFixture | null { + if (guildId === '') { + return null; + } + return { + guildId, + guildName: '', + textChannelId: '', + textChannelName: '', + everyoneRoleId: '', + }; +} + +/** openFriends открывает раздел «Друзья». */ +async function openFriends(page: Page): Promise { + await page.getByRole('link', { name: 'Друзья' }).click(); + // Заголовков «Друзья» на экране три (сайдбар, рейка и контент) — берём первый. + await expect(page.getByRole('heading', { name: 'Друзья' }).first()).toBeVisible({ + timeout: 20_000, + }); + await expect(page).toHaveURL(/\/app\/friends/u); +} + +/** + * ensureFriendsListOpen раскрывает список друзей, если он свёрнут: состояние + * живёт в localStorage и по умолчанию закрыто. + */ +async function ensureFriendsListOpen(page: Page): Promise { + const region = page.getByTestId('friends-list-region'); + await expect(region).toBeAttached({ timeout: 20_000 }); + if ((await region.getAttribute('data-state')) !== 'open') { + await page.getByTestId('friends-list-toggle').click({ timeout: 15_000 }); + } + await expect(region).toHaveAttribute('data-state', 'open', { timeout: 15_000 }); +} + +/** memberGuildNames читает список серверов пользователя. */ +async function memberGuildNames(api: APIRequestContext): Promise { + const response = await repeat429(() => api.get(`${API}/users/@me/guilds`)); + expect(response.ok(), `мои серверы: ${await response.text()}`).toBeTruthy(); + return ((await response.json()) as { guilds: { name: string }[] }).guilds.map( + (guild) => guild.name, + ); +} + +async function friendIds(api: APIRequestContext): Promise { + const response = await repeat429(() => api.get(`${API}/users/@me/relationships`)); + if (!response.ok()) { + return []; + } + return ((await response.json()) as { friends: { user_id: string }[] }).friends.map( + (friend) => friend.user_id, + ); +} + +async function blockedUserIds(api: APIRequestContext): Promise { + const response = await repeat429(() => api.get(`${API}/users/@me/relationships`)); + if (!response.ok()) { + return []; + } + return ((await response.json()) as { blocked: { user_id: string }[] }).blocked.map( + (blocked) => blocked.user_id, + ); +} + +async function channelMessages(api: APIRequestContext, channelId: string): Promise { + const response = await repeat429(() => api.get(`${API}/channels/${channelId}/messages`)); + if (!response.ok()) { + return []; + } + return ((await response.json()) as { messages: unknown[] }).messages; +} + +/** errorCode читает код ошибки из единого конверта API (AGENT.md 8.1). */ +async function errorCode(response: APIResponse): Promise { + const payload = (await response.json().catch(() => ({}))) as { error?: { code?: string } }; + return payload.error?.code ?? ''; +}