Files
glchat/web/e2e/personal-dm.spec.ts
T
grendervill 8c2bb3e18c test(web): e2e личных серверов, DM, друзей и блокировок
Сценариев личных серверов и личных бесед в матрице 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).
2026-09-26 11:06:46 +03:00

416 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> {
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<void> {
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<string[]> {
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<string[]> {
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<string[]> {
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<unknown[]> {
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<string> {
const payload = (await response.json().catch(() => ({}))) as { error?: { code?: string } };
return payload.error?.code ?? '';
}