import { afterEach, describe, expect, it } from 'vitest'; import { waitFor } from '@testing-library/react'; import { QueryClient } from '@tanstack/react-query'; import { GatewayOp, parseGatewaySnapshot } from '@/api/gateway'; import { guildMembersQueryKey, guildQueryKey, guildRolesQueryKey } from '@/api/guilds'; import { setQueryClient } from '@/lib/queryClient'; import { dispatchGatewayEvent, useGatewayStore } from '@/stores/gateway'; import { useSessionStore } from '@/stores/session'; import { installFetch, json, makeReadySnapshot, recordedRequests } from './helpers'; /** Наполняет стор снапшотом READY с двумя серверами и комнатами. */ function seedSession() { const snapshot = parseGatewaySnapshot( makeReadySnapshot([ { id: 'g-1', name: 'Первый', channels: [ { id: 'c-1', name: 'общий', position: 0 }, { id: 'c-2', name: 'флудилка', position: 1 }, ], }, { id: 'g-2', name: 'Второй', channels: [{ id: 'c-9', name: 'болталка', position: 0 }], }, ]), ); if (snapshot === null) { throw new Error('снапшот не разобрался'); } useSessionStore.getState().applyReady(snapshot); } function makeRecordingClient(): { client: QueryClient; invalidated: string[][] } { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const invalidated: string[][] = []; const original = client.invalidateQueries.bind(client); client.invalidateQueries = (filters?: { queryKey?: readonly unknown[] }) => { invalidated.push((filters?.queryKey ?? []).map((part) => String(part))); return original(filters); }; setQueryClient(client); return { client, invalidated }; } afterEach(() => { useSessionStore.getState().reset(); useGatewayStore.setState({ status: 'idle', heartbeatIntervalMs: null, sessionId: null, attempts: 0, invalidated: false, }); setQueryClient(null); }); describe('диспетчер событий шлюза', () => { it('USER_UPDATE обновляет профиль текущего пользователя', () => { seedSession(); expect(useSessionStore.getState().user?.display_name).toBe('Alice'); dispatchGatewayEvent({ op: 0, t: 'USER_UPDATE', s: 2, d: { user: { id: 'user-1', username: 'alice', display_name: 'Алиса', is_instance_admin: false, badges: ['early'], avatar_file_id: 'file-7', custom_status: 'Пишу код', }, }, }); const user = useSessionStore.getState().user; expect(user?.display_name).toBe('Алиса'); expect(user?.avatar_file_id).toBe('file-7'); expect(user?.custom_status).toBe('Пишу код'); expect(user?.badges).toEqual(['early']); }); it('USER_UPDATE про другого пользователя ничего не меняет', () => { seedSession(); dispatchGatewayEvent({ op: 0, t: 'USER_UPDATE', s: 3, d: { user: { id: 'user-2', username: 'bob', display_name: 'Bob' } }, }); expect(useSessionStore.getState().user?.display_name).toBe('Alice'); }); it('GUILD_UPDATE меняет имя сервера', () => { seedSession(); dispatchGatewayEvent({ op: 0, t: 'GUILD_UPDATE', s: 4, d: { guild_id: 'g-2', name: 'Второй (обновлён)' }, }); expect(useSessionStore.getState().guilds.find((g) => g.id === 'g-2')?.name).toBe( 'Второй (обновлён)', ); }); it('GUILD_DELETE удаляет сервер и выбирает первый оставшийся с комнатой', () => { seedSession(); useSessionStore.getState().selectChannel('g-2', 'c-9'); dispatchGatewayEvent({ op: 0, t: 'GUILD_DELETE', s: 5, d: { guild_id: 'g-2' } }); const state = useSessionStore.getState(); expect(state.guilds.map((guild) => guild.id)).toEqual(['g-1']); expect(state.selectedGuildId).toBe('g-1'); expect(state.selectedChannelId).toBe('c-1'); }); it('GUILD_DELETE последнего сервера очищает выбор', () => { seedSession(); useSessionStore.getState().selectChannel('g-2', 'c-9'); dispatchGatewayEvent({ op: 0, t: 'GUILD_DELETE', s: 6, d: { guild_id: 'g-1' } }); dispatchGatewayEvent({ op: 0, t: 'GUILD_DELETE', s: 7, d: { guild_id: 'g-2' } }); const state = useSessionStore.getState(); expect(state.guilds).toEqual([]); expect(state.selectedGuildId).toBeNull(); expect(state.selectedChannelId).toBeNull(); }); 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 }, }); 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?.can_send).toBe(false); dispatchGatewayEvent({ op: 0, t: 'CHANNEL_UPDATE', s: 9, d: { id: 'c-3', guild_id: 'g-1', name: 'новости', type: 'text', position: 2 }, }); 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 удаляет комнату и переносит выбор на первую доступную', () => { seedSession(); useSessionStore.getState().selectChannel('g-1', 'c-1'); dispatchGatewayEvent({ op: 0, t: 'CHANNEL_DELETE', s: 10, d: { channel_id: 'c-1', guild_id: 'g-1' }, }); const state = useSessionStore.getState(); expect( state.guilds.find((guild) => guild.id === 'g-1')?.channels.map((channel) => channel.id), ).toEqual(['c-2']); 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'), []); client.setQueryData(guildRolesQueryKey('g-1'), []); client.setQueryData(guildQueryKey('g-1'), {}); dispatchGatewayEvent({ op: 0, t: 'MEMBER_UPDATE', s: 11, d: { guild_id: 'g-1', user_id: 'user-2', role_ids: ['role-3'], role_id: 'role-3', role_added: true, }, }); await waitFor(() => { expect(invalidated.flat()).toContain('members'); }); expect(client.getQueryState(guildMembersQueryKey('g-1'))?.isInvalidated).toBe(true); expect(client.getQueryState(guildRolesQueryKey('g-1'))?.isInvalidated).toBe(true); expect(client.getQueryState(guildQueryKey('g-1'))?.isInvalidated).toBe(true); expect(invalidated.map((key) => key.join('/'))).toEqual( expect.arrayContaining(['guilds/g-1/members', 'guilds/g-1/roles', 'guilds/g-1']), ); }); it('ROLE_DELETE инвалидирует данные сервера', async () => { const { client, invalidated } = makeRecordingClient(); client.setQueryData(guildMembersQueryKey('g-1'), []); dispatchGatewayEvent({ op: 0, t: 'ROLE_DELETE', s: 12, d: { guild_id: 'g-1', role_id: 'role-3' }, }); await waitFor(() => { expect(client.getQueryState(guildMembersQueryKey('g-1'))?.isInvalidated).toBe(true); }); expect(invalidated.map((key) => key.join('/'))).toContain('guilds/g-1/roles'); }); it('MEMBER_REMOVE инвалидирует список участников', async () => { const { client } = makeRecordingClient(); client.setQueryData(guildMembersQueryKey('g-1'), []); dispatchGatewayEvent({ op: 0, t: 'MEMBER_REMOVE', s: 13, d: { guild_id: 'g-1', user_id: 'user-2' }, }); await waitFor(() => { expect(client.getQueryState(guildMembersQueryKey('g-1'))?.isInvalidated).toBe(true); }); }); it('GUILD_CREATE подтягивает сервер из REST и кладёт его в стор', async () => { const { client } = makeRecordingClient(); const guild = { id: 'g-3', name: 'Новый', owner_id: 'user-1', is_main: false, member_count: 1, my_role_ids: [], my_permissions: [], }; // В событии только guild_id — клиент обязан сходить за сводкой в REST. const fetchMock = installFetch([ { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [guild] }), }, ]); void client; dispatchGatewayEvent({ op: 0, t: 'GUILD_CREATE', s: 14, d: { guild_id: 'g-3' } }); await waitFor(() => { expect(useSessionStore.getState().guilds.some((item) => item.id === 'g-3')).toBe(true); }); expect(useSessionStore.getState().guilds.find((item) => item.id === 'g-3')?.name).toBe('Новый'); expect( recordedRequests(fetchMock).some((request) => request.url.includes('/users/@me/guilds')), ).toBe(true); }); it('неизвестные и повреждённые события не ломают диспетчер', () => { seedSession(); dispatchGatewayEvent({ op: 0, t: 'SOMETHING_NEW', s: 15, d: { x: 1 } }); dispatchGatewayEvent({ op: 0, t: 'CHANNEL_CREATE', s: 16, d: { name: 'без id' } }); dispatchGatewayEvent({ op: 0, t: 'GUILD_DELETE', s: 17, d: null }); dispatchGatewayEvent({ op: 0, t: 'USER_UPDATE', s: 18, d: 'мусор' }); expect(useSessionStore.getState().guilds).toHaveLength(2); expect(useSessionStore.getState().user?.display_name).toBe('Alice'); }); it('оп-коды соответствуют AGENT.md §8.3', () => { expect(GatewayOp.INVALID_SESSION).toBe(4); expect(GatewayOp.RECONNECT).toBe(7); expect(GatewayOp.HELLO).toBe(10); expect(GatewayOp.HEARTBEAT_ACK).toBe(11); }); });