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:
@@ -5,7 +5,8 @@ import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'coverage', 'node_modules'] },
|
||||
// Артефакты Playwright (test-results*, отчёты) не линтуем: их пишет прогон.
|
||||
{ ignores: ['dist', 'coverage', 'node_modules', 'test-results*', 'playwright-report'] },
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
|
||||
|
||||
@@ -215,7 +215,9 @@ export function parseMemberRemove(value: unknown): MemberRemoveEvent | null {
|
||||
export function parseRoleEvent(value: unknown): RoleEvent | null {
|
||||
const record = asRecord(value);
|
||||
const guildId = asString(record?.['guild_id']);
|
||||
const roleId = asString(record?.['role_id']);
|
||||
// Сервер шлёт тело роли (поле id), старый формат — ссылку role_id:
|
||||
// принимаем оба, иначе событие роли отбрасывалось и список не обновлялся.
|
||||
const roleId = asString(record?.['role_id']) ?? asString(record?.['id']);
|
||||
if (record === null || guildId === undefined || roleId === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ interface GatewayState {
|
||||
invalidated: boolean;
|
||||
connect: (userId: string) => void;
|
||||
disconnect: () => void;
|
||||
/** Сессия отозвана сервером (logout-all, смена пароля, бан): экран входа. */
|
||||
markInvalidated: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -107,6 +109,10 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
set({ ...defaults });
|
||||
},
|
||||
|
||||
/** markInvalidated — сессия отозвана сервером: показываем экран входа. */
|
||||
markInvalidated: () => {
|
||||
set({ invalidated: true, status: 'disconnected' });
|
||||
},
|
||||
reset: () => {
|
||||
disconnectGateway();
|
||||
set({ ...defaults });
|
||||
@@ -199,7 +205,10 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
}
|
||||
const guildId = channel.guild_id ?? session.selectedGuildId;
|
||||
if (guildId !== null) {
|
||||
session.upsertChannel(guildId, channel);
|
||||
// Права на комнату персональные: тело события их не несёт, поэтому
|
||||
// перечитываем список — иначе новая или переименованная комната не
|
||||
// появится в сайдбаре без перезагрузки (AGENT.md 11.6).
|
||||
void session.reloadGuildChannels(guildId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -274,10 +283,12 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
}
|
||||
case 'MEMBER_UPDATE':
|
||||
case 'MEMBER_REMOVE': {
|
||||
// Роли участника влияют на права: перечитываем участников и карточку сервера.
|
||||
// Роли участника влияют на права: перечитываем участников, карточку
|
||||
// сервера и список комнат (приватные комнаты открываются ролью).
|
||||
const guildId = parseGuildId(event.d);
|
||||
if (guildId !== null) {
|
||||
void invalidateMembershipQueries(guildId);
|
||||
void session.reloadGuildChannels(guildId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -287,6 +298,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
const role = parseRoleEvent(event.d);
|
||||
if (role !== null) {
|
||||
void invalidateMembershipQueries(role.guild_id);
|
||||
void session.reloadGuildChannels(role.guild_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -335,6 +347,16 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'SESSION_INVALIDATED': {
|
||||
// Сервер отозвал сессии (logout-all, смена пароля, бан, удаление):
|
||||
// уходим на экран входа, не дожидаясь следующего запроса (AGENT.md 11.6).
|
||||
useGatewayStore.getState().markInvalidated();
|
||||
session.reset();
|
||||
messages.reset();
|
||||
useVoiceConnectionStore.getState().reset();
|
||||
void invalidateCurrentUser();
|
||||
return;
|
||||
}
|
||||
case 'GUILD_CHANNELS_SYNC': {
|
||||
// Права комнаты изменились: видимость может поменяться у всех
|
||||
// участников, поэтому список комнат перечитываем целиком (AGENT.md 11.6).
|
||||
|
||||
@@ -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'), []);
|
||||
|
||||
Reference in New Issue
Block a user