9c001fedfe
Клиент React 19 + TanStack Query + Zustand:
- вход (поле TOTP появляется на `auth.2fa_required`), регистрация с проверками
и учётом `registration_enabled`, онбординг первого входа с возможностью
пропустить, редирект `/` и страница `/status`;
- защита маршрутов: неавторизованных — на `/login`, без онбординга — на
`/onboarding`;
- оболочка `/app`: рейка серверов, сайдбар категорий и комнат, шапка сервера,
панель пользователя, модалки создания сервера и комнаты, экран «нет серверов»
со вступлением в главный сервер, заглушка комнаты до Фазы 2;
- настройки: профиль, безопасность (step-up, сессии, logout-all, 2FA с
локальным QR и кодами восстановления), внешний вид, админ-раздел инстанса;
- Gateway-клиент: HELLO/IDENTIFY/RESUME, heartbeat с ожиданием ACK,
экспоненциальное переподключение, разбор `INVALID_SESSION {reason,resumable}`;
- диспетчер событий: READY/RESUMED/USER_UPDATE/GUILD_UPDATE/GUILD_DELETE/
CHANNEL_* применяются к стору, GUILD_CREATE догружается по REST,
MEMBER_*/ROLE_* инвалидируют запросы участников, ролей и карточки сервера;
- i18n ru/en, 66 тестов Vitest, `check`/`lint`/`test`/`build` зелёные.
Серверные правки под клиент:
- `totp_enabled` в профиле (`GET /users/@me`, вход, регистрация) — клиенту
нужно знать, требовать ли код при step-up;
- `POST /auth/2fa/setup` отдаёт `otpauth_url` и `qr_png` (data-URI): QR-код
рисуется локально, внешние сервисы генерации QR не используются;
- права в ответах — имена (`VIEW_CHANNEL|SEND_MESSAGES`), клиент сверяет имена.
116 lines
3.4 KiB
TypeScript
116 lines
3.4 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { act } from '@testing-library/react';
|
|
|
|
import { parseGatewaySnapshot } from '@/api/gateway';
|
|
import { dispatchGatewayEvent, useGatewayStore } from '@/stores/gateway';
|
|
import { useSessionStore } from '@/stores/session';
|
|
import { makeReadySnapshot } from './helpers';
|
|
|
|
afterEach(() => {
|
|
useSessionStore.getState().reset();
|
|
useGatewayStore.setState({
|
|
status: 'idle',
|
|
heartbeatIntervalMs: null,
|
|
sessionId: null,
|
|
attempts: 0,
|
|
invalidated: false,
|
|
});
|
|
});
|
|
|
|
describe('session store', () => {
|
|
it('applies a READY snapshot: guilds, channels and session id', () => {
|
|
const snapshot = parseGatewaySnapshot(
|
|
makeReadySnapshot([
|
|
{
|
|
id: 'g-1',
|
|
name: 'Main',
|
|
is_main: true,
|
|
channels: [
|
|
{ id: 'c-1', name: 'general', position: 0 },
|
|
{ id: 'c-2', name: 'voice', type: 'voice', position: 1 },
|
|
],
|
|
},
|
|
]),
|
|
);
|
|
expect(snapshot).not.toBeNull();
|
|
|
|
act(() => {
|
|
dispatchGatewayEvent({
|
|
op: 0,
|
|
t: 'READY',
|
|
s: 1,
|
|
d: makeReadySnapshot([
|
|
{
|
|
id: 'g-1',
|
|
name: 'Main',
|
|
is_main: true,
|
|
channels: [
|
|
{ id: 'c-1', name: 'general', position: 0 },
|
|
{ id: 'c-2', name: 'voice', type: 'voice', position: 1 },
|
|
],
|
|
},
|
|
]),
|
|
});
|
|
});
|
|
|
|
const state = useSessionStore.getState();
|
|
expect(state.guilds).toHaveLength(1);
|
|
expect(state.guilds[0]?.name).toBe('Main');
|
|
expect(state.guilds[0]?.channels.map((channel) => channel.id)).toEqual(['c-1', 'c-2']);
|
|
expect(state.sessionId).toBe('session-1');
|
|
expect(state.user?.display_name).toBe('Alice');
|
|
expect(useGatewayStore.getState().status).toBe('connected');
|
|
expect(useGatewayStore.getState().heartbeatIntervalMs).toBe(45_000);
|
|
});
|
|
|
|
it('keeps channels with can_send=false (only can_view=false is hidden)', () => {
|
|
act(() => {
|
|
dispatchGatewayEvent({
|
|
op: 0,
|
|
t: 'READY',
|
|
s: 2,
|
|
d: makeReadySnapshot([
|
|
{
|
|
id: 'g-1',
|
|
name: 'Main',
|
|
channels: [
|
|
{ id: 'c-readonly', name: 'announcements', can_send: false },
|
|
{ id: 'c-hidden', name: 'secret', can_view: false },
|
|
],
|
|
},
|
|
]),
|
|
});
|
|
});
|
|
|
|
const channels = useSessionStore.getState().guilds[0]?.channels ?? [];
|
|
expect(channels.map((channel) => channel.id)).toEqual(['c-readonly', 'c-hidden']);
|
|
});
|
|
|
|
it('ignores unknown events and malformed snapshots', () => {
|
|
act(() => {
|
|
dispatchGatewayEvent({ op: 0, t: 'SOMETHING_NEW', s: 3, d: { anything: true } });
|
|
dispatchGatewayEvent({ op: 0, t: 'READY', s: 4, d: { guilds: [] } });
|
|
});
|
|
|
|
expect(useSessionStore.getState().guilds).toEqual([]);
|
|
expect(useSessionStore.getState().user).toBeNull();
|
|
});
|
|
|
|
it('drops a guild and clears the selection when leaving', () => {
|
|
act(() => {
|
|
dispatchGatewayEvent({
|
|
op: 0,
|
|
t: 'READY',
|
|
s: 5,
|
|
d: makeReadySnapshot([{ id: 'g-1', name: 'Main' }]),
|
|
});
|
|
useSessionStore.getState().selectChannel('g-1', null);
|
|
useSessionStore.getState().removeGuild('g-1');
|
|
});
|
|
|
|
const state = useSessionStore.getState();
|
|
expect(state.guilds).toEqual([]);
|
|
expect(state.selectedGuildId).toBeNull();
|
|
});
|
|
});
|