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).
This commit is contained in:
@@ -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<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 ?? '';
|
||||
}
|
||||
Reference in New Issue
Block a user