313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
|
|
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 заменяет её', () => {
|
||
|
|
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,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
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({
|
||
|
|
op: 0,
|
||
|
|
t: 'CHANNEL_UPDATE',
|
||
|
|
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('новости');
|
||
|
|
});
|
||
|
|
|
||
|
|
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('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);
|
||
|
|
});
|
||
|
|
});
|