fix(realtime): события ролей, комнат и отзыв сессий без перезагрузки

Дефекты, найденные e2e-матрицей AGENT.md 11.6 (пункты 1, 2, 7, 12):

- ROLE_CREATE/ROLE_UPDATE уходили телом роли без guild_id, а клиент ждал
  ссылку {guild_id, role_id} — событие отбрасывалось, роли у второго
  участника не обновлялись. В rolePayload добавлено guild_id, parseRoleEvent
  принимает и тело роли, и старую ссылку;
- выдача роли (MEMBER_UPDATE) и правки ролей не перечитывали список комнат,
  поэтому приватная комната не появлялась в сайдбаре без F5 — теперь клиент
  перечитывает комнаты по этим событиям;
- CHANNEL_CREATE/CHANNEL_UPDATE уходили телом комнаты с нулевым can_view
  (права персональные), и клиент прятал комнату по этому признаку. Сервер
  шлёт тело события без вычисленных прав плюс GUILD_CHANNELS_SYNC, а клиент
  по событию комнаты перечитывает список;
- отзыв сессий (logout-all, смена пароля, админский сброс и «выйти везде»,
  бан, удаление) не доходил до Gateway: второе устройство оставалось в
  приложении. Добавлен gateway.InvalidateUser — клиент получает
  INVALID_SESSION и закрывает соединение; клиент дополнительно обрабатывает
  событие SESSION_INVALIDATED.

Тесты: gateway (отзыв сессий закрывает соединения и не задевает чужого),
web (событие SESSION_INVALIDATED, перечитывание комнат по CHANNEL_CREATE).
This commit is contained in:
2026-09-22 20:10:53 +03:00
parent ff2fc878b0
commit d241714a6d
10 changed files with 188 additions and 26 deletions
+56 -17
View File
@@ -140,28 +140,49 @@ describe('диспетчер событий шлюза', () => {
expect(state.selectedChannelId).toBeNull();
});
it('CHANNEL_CREATE добавляет комнату, CHANNEL_UPDATE заменяет её', () => {
it('CHANNEL_CREATE и CHANNEL_UPDATE перечитывают комнаты с сервера', async () => {
// Права на комнату персональные: тело события их не несёт, поэтому клиент
// перечитывает список, а не подставляет комнату из кадра (AGENDA.md 11.6).
installFetch([
{
match: '/api/v1/guilds/g-1/channels',
response: () =>
json({
channels: [
{ id: 'c-1', guild_id: 'g-1', name: 'общий', type: 'text', position: 0 },
{ id: 'c-2', guild_id: 'g-1', name: 'флуд', type: 'text', position: 1 },
{
id: 'c-3',
guild_id: 'g-1',
name: 'новости',
type: 'text',
position: 2,
can_send: false,
},
],
}),
},
]);
seedSession();
dispatchGatewayEvent({
op: 0,
t: 'CHANNEL_CREATE',
s: 8,
d: {
id: 'c-3',
guild_id: 'g-1',
name: 'анонсы',
type: 'text',
position: 2,
can_send: false,
},
d: { id: 'c-3', guild_id: 'g-1', name: 'анонсы', type: 'text', position: 2 },
});
await waitFor(() => {
const channels = useSessionStore
.getState()
.guilds.find((guild) => guild.id === 'g-1')
?.channels.map((channel) => channel.name);
expect(channels).toEqual(['общий', 'флуд', 'новости']);
});
const created = useSessionStore
.getState()
.guilds.find((guild) => guild.id === 'g-1')
?.channels.find((channel) => channel.id === 'c-3');
expect(created?.name).toBe('анонсы');
expect(created?.can_send).toBe(false);
dispatchGatewayEvent({
@@ -170,13 +191,13 @@ describe('диспетчер событий шлюза', () => {
s: 9,
d: { id: 'c-3', guild_id: 'g-1', name: 'новости', type: 'text', position: 2 },
});
const updated = useSessionStore
.getState()
.guilds.find((guild) => guild.id === 'g-1')
?.channels.filter((channel) => channel.id === 'c-3');
expect(updated).toHaveLength(1);
expect(updated?.[0]?.name).toBe('новости');
await waitFor(() => {
const updated = useSessionStore
.getState()
.guilds.find((guild) => guild.id === 'g-1')
?.channels.filter((channel) => channel.id === 'c-3');
expect(updated).toHaveLength(1);
});
});
it('CHANNEL_DELETE удаляет комнату и переносит выбор на первую доступную', () => {
@@ -197,6 +218,24 @@ describe('диспетчер событий шлюза', () => {
expect(state.selectedChannelId).toBe('c-2');
});
it('SESSION_INVALIDATED переводит клиент на экран входа', () => {
// Сервер отзывает сессии при logout-all, смене пароля и бане: второе
// устройство не должно оставаться в приложении до следующего запроса.
seedSession();
useGatewayStore.setState({ invalidated: false, status: 'connected' });
dispatchGatewayEvent({
op: 0,
t: 'SESSION_INVALIDATED',
s: 42,
d: { reason: 'logout_all' },
});
expect(useGatewayStore.getState().invalidated).toBe(true);
expect(useGatewayStore.getState().status).toBe('disconnected');
expect(useSessionStore.getState().guilds).toEqual([]);
});
it('MEMBER_UPDATE с ролями инвалидирует участников, роли и карточку сервера', async () => {
const { client, invalidated } = makeRecordingClient();
client.setQueryData(guildMembersQueryKey('g-1'), []);