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 ?? ''; }