test(e2e): матрица 11.6 — пункт 12 без fixme и возврат сервера в пункте 11

Пункт 12 включён: клиент уходит на экран входа при отзыве сессий, в том числе
когда открыт сервер (дефект исправлен в 751ecc6), а смена пароля на сервере
больше не рвёт соединение текущего устройства (05409d0).

Пункт 11 теперь проверяет то, ради чего он написан: после кика и повторного
входа плитка сервера возвращается в рейку без перезагрузки (правка клиента в
ea1b4a7). Возврат пароля пробного участника перенесён в finally: падение теста
не оставляет постоянный аккаунт с временным паролем, а неудачный возврат
сообщается явной проверкой.

Прогон: 13 passed (2,9 мин), лог — build/e2e-realtime-run4.log.
This commit is contained in:
2026-09-22 22:28:10 +03:00
parent 05409d0914
commit dcafb10046
+46 -35
View File
@@ -472,18 +472,22 @@ test.describe('realtime: пункты 8–13 (чат, состояния, мод
await expect(memberRow).toHaveCount(0, { timeout: 20_000 }); await expect(memberRow).toHaveCount(0, { timeout: 20_000 });
// Возврат в сервер: он на время открывается — приглашения тратят // Возврат в сервер: он на время открывается — приглашения тратят
// суточную квоту 10/24ч (AGENT.md 8.6). Участник снова в составе. // суточную квоту 10/24ч (AGENT.md 8.6). Участник снова в составе, и
// Плитка сервера в рейке B при этом не возвращается без перезагрузки: // плитка сервера возвращается в рейку B без перезагрузки: по GUILD_CREATE
// по GUILD_CREATE клиент берёт список через fetchQuery и получает свежий // клиент перечитывает список серверов (web/src/stores/gateway.ts,
// кэш myGuilds без нового сервера (web/src/stores/gateway.ts, // addGuildFromRest), а не берёт устаревший кэш myGuilds.
// addGuildFromRest) — дефект описан в отчёте, поэтому состав проверяем
// по ответу сервера.
await setPublic(ownerApi, fixture.guildId, true); await setPublic(ownerApi, fixture.guildId, true);
const rejoin = await memberApi.post(`${API}/guilds/${fixture.guildId}/join`); const rejoin = await memberApi.post(`${API}/guilds/${fixture.guildId}/join`);
expect(rejoin.ok(), `возврат в сервер: ${await rejoin.text()}`).toBeTruthy(); expect(rejoin.ok(), `возврат в сервер: ${await rejoin.text()}`).toBeTruthy();
await expect await expect
.poll(async () => guildMemberIds(ownerApi, fixture?.guildId ?? ''), { timeout: 20_000 }) .poll(async () => guildMemberIds(ownerApi, fixture?.guildId ?? ''), { timeout: 20_000 })
.toContain(member.id); .toContain(member.id);
await expect(
memberSession.page.getByRole('link', {
name: `Открыть сервер ${fixture.guildName}`,
exact: true,
}),
).toBeVisible({ timeout: 20_000 });
await setPublic(ownerApi, fixture.guildId, false); await setPublic(ownerApi, fixture.guildId, false);
// Бан: участник снова исключён, попал в список банов и не может вернуться // Бан: участник снова исключён, попал в список банов и не может вернуться
@@ -610,25 +614,17 @@ test.describe('realtime: пункты 8–13 (чат, состояния, мод
// Тест идёт последним: logout-all и смена пароля отзывают подготовленные // Тест идёт последним: logout-all и смена пароля отзывают подготовленные
// сессии файла, поэтому остальным тестам они должны остаться живыми. // сессии файла, поэтому остальным тестам они должны остаться живыми.
// //
// Дефект: сервер отзывает сессии и присылает второму устройству
// {"op":4,"d":{"reason":"password_changed","resumable":false}} + CLOSE 1000
// (проверено перехватом WS на стенде), но устройство, находящееся внутри
// сервера (/app/<сервер>/<комната>), остаётся на месте: запроса
// GET /users/@me после отзыва не происходит, экран входа не появляется.
// Скрипт build/verify-session-invalidation.mjs показывает уход на /login,
// когда второе устройство находится на /app без открытого сервера, поэтому
// дело в ветке клиента, которая срабатывает при пустом снапшоте серверов.
test.fixme(
true,
'второе устройство с открытым сервером не уходит на /login после отзыва сессии (профиль не перечитывается)',
);
// Оба действия подтверждаются на втором устройстве без перезагрузки — // Оба действия подтверждаются на втором устройстве без перезагрузки —
// сервер шлёт INVALID_SESSION, клиент перечитывает профиль, получает 401 и // сервер шлёт INVALID_SESSION, клиент сразу уходит на экран входа, даже
// уводит устройство на экран входа (AGENT.md 11.6). // когда открыт сервер (AGENT.md 11.6).
const ownerApi = await apiAs(playwright, owner.state); const ownerApi = await apiAs(playwright, owner.state);
const memberApi = await apiAs(playwright, member.state); const memberApi = await apiAs(playwright, member.state);
const sessions: Session[] = []; const sessions: Session[] = [];
let fixture: GuildFixture | null = null; let fixture: GuildFixture | null = null;
// Устройство, сменившее пароль: с него возвращаем исходный в finally, чтобы
// падение теста не оставило постоянный пробный аккаунт с временным паролем.
let passwordDevice: Page | null = null;
let passwordChanged = false;
try { try {
fixture = await prepareGuild(ownerApi, memberApi); fixture = await prepareGuild(ownerApi, memberApi);
@@ -655,29 +651,23 @@ test.describe('realtime: пункты 8–13 (чат, состояния, мод
sessions.push(third, fourth); sessions.push(third, fourth);
await openWorkspace(third.page, fixture); await openWorkspace(third.page, fixture);
await openWorkspace(fourth.page, fixture); await openWorkspace(fourth.page, fixture);
passwordDevice = third.page;
await changePassword(third.page, member.password, MEMBER_TEMP_PASSWORD); await changePassword(third.page, member.password, MEMBER_TEMP_PASSWORD);
passwordChanged = true;
const revoked = await fourth.page.evaluate(async () => { const revoked = await fourth.page.evaluate(async () => {
const response = await fetch('/api/v1/users/@me', { credentials: 'same-origin' }); const response = await fetch('/api/v1/users/@me', { credentials: 'same-origin' });
return response.status; return response.status;
}); });
expect(revoked, 'сессия второго устройства отозвана сменой пароля').toBe(401); expect(revoked, 'сессия второго устройства отозвана сменой пароля').toBe(401);
await expect(fourth.page).toHaveURL(/\/login/u, { timeout: 30_000 }); await expect(fourth.page).toHaveURL(/\/login/u, { timeout: 30_000 });
// Пароль возвращаем исходный из уцелевшей сессии устройства 3.
const restored = await third.page.evaluate(async (password: string) => {
const response = await fetch('/api/v1/users/@me/password', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
current_password: 'Rt-Probe-Member-Temp-9x',
new_password: password,
}),
});
return response.status;
}, member.password);
expect(restored, 'пароль пробного аккаунта возвращён').toBe(200);
} finally { } finally {
if (passwordChanged && passwordDevice !== null) {
const restored = await restoreMemberPassword(passwordDevice, member.password);
expect(
restored,
`пароль пробного аккаунта ${MEMBER_LOGIN.username} возвращён (${restored})`,
).toBe(200);
}
for (const device of sessions) { for (const device of sessions) {
await device.context.close(); await device.context.close();
} }
@@ -938,6 +928,27 @@ async function changePassword(page: Page, current: string, next: string): Promis
await expect(content.getByRole('status')).toContainText('Пароль изменён', { timeout: 20_000 }); await expect(content.getByRole('status')).toContainText('Пароль изменён', { timeout: 20_000 });
} }
/**
* restoreMemberPassword возвращает пробному участнику исходный пароль с
* устройства, которое его сменило. Возврат идёт из finally: если он не удался,
* постоянный аккаунт останется с временным паролем и следующие прогоны файла
* не смогут войти — поэтому тест обязан сообщить об этом явно.
*/
async function restoreMemberPassword(page: Page, password: string): Promise<number> {
return page.evaluate(
async (payload: { current: string; next: string }) => {
const response = await fetch('/api/v1/users/@me/password', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ current_password: payload.current, new_password: payload.next }),
});
return response.status;
},
{ current: MEMBER_TEMP_PASSWORD, next: password },
);
}
/** sessionFrom открывает браузерный контекст на готовой сессии аккаунта. */ /** sessionFrom открывает браузерный контекст на готовой сессии аккаунта. */
async function sessionFrom(browser: Browser, state: StorageState): Promise<Session> { async function sessionFrom(browser: Browser, state: StorageState): Promise<Session> {
const context = await browser.newContext({ storageState: state }); const context = await browser.newContext({ storageState: state });