feat(cli): включение 2FA инстанс-админа командой totp-setup
2FA обязательна для инстанс-администраторов, но до её включения вход закрыт — получался замкнутый круг. Добавлены команды обслуживания: - `glchat totp-setup --email <admin>`: создаёт секрет, подтверждает его кодом, печатает секрет, otpauth-ссылку и 8 резервных кодов (каждый одноразовый); - `glchat totp-reset --email <admin>`: удаляет секрет при потере устройства; - код `auth.2fa_enrollment_required` с подсказкой, какую команду выполнить; - установщик: флаг `--admin-2fa` для автоматического включения (по умолчанию печатает подсказку, чтобы секреты не оседали в логах установки). Проверено сквозным прогоном локально: bootstrap → 403 на входе без 2FA → totp-setup → вход с TOTP-кодом → профиль, серверы, комнаты, роли, участники, аудит, создание сервера админом, 429 на шестой попытке входа, секретов в логах нет.
This commit is contained in:
@@ -2,9 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/bootstrap"
|
||||
@@ -23,6 +27,10 @@ func runCLI(args []string) int {
|
||||
return cliResetPassword(args[1:])
|
||||
case "make-admin", "remove-admin":
|
||||
return cliSetInstanceAdmin(args[0], args[1:])
|
||||
case "totp-setup":
|
||||
return cliTOTPSetup(args[1:])
|
||||
case "totp-reset":
|
||||
return cliTOTPReset(args[1:])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "glchat: неизвестная команда %q\n", args[0])
|
||||
return 2
|
||||
@@ -136,6 +144,86 @@ func cliResetPassword(args []string) int {
|
||||
})
|
||||
}
|
||||
|
||||
// cliTOTPSetup включает 2FA для пользователя (обычно инстанс-администратора):
|
||||
// 2FA обязательна для админов (AGENT.md 7.19), а до её включения вход закрыт,
|
||||
// поэтому первичная настройка выполняется командой обслуживания.
|
||||
func cliTOTPSetup(args []string) int {
|
||||
flags := flag.NewFlagSet("totp-setup", flag.ContinueOnError)
|
||||
email := flags.String("email", "", "email пользователя (обычно администратора инстанса)")
|
||||
positional := flags.Args()
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if *email == "" && len(positional) > 0 {
|
||||
*email = positional[0]
|
||||
}
|
||||
if *email == "" {
|
||||
fmt.Fprintln(os.Stderr, "glchat totp-setup: укажите --email")
|
||||
return 2
|
||||
}
|
||||
|
||||
return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error {
|
||||
user, err := authService.UserByEmail(ctx, *email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("пользователь с таким email не найден")
|
||||
}
|
||||
setup, err := authService.SetupTOTP(ctx, user.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrTOTPAlreadyEnabled) {
|
||||
return fmt.Errorf("2FA уже включена: сбросьте секрет через glchat totp-reset")
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Код подтверждения считаем из секрета: команда выполняется на сервере,
|
||||
// где секрет и так доступен (расшифровка идёт внутри сервиса).
|
||||
code, err := totp.GenerateCode(setup.Secret, time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf("сгенерировать код подтверждения: %w", err)
|
||||
}
|
||||
codes, err := authService.EnableTOTP(ctx, user.ID, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("2FA включена для %s\n", user.Username)
|
||||
fmt.Printf("секрет: %s\n", setup.Secret)
|
||||
fmt.Printf("otpauth: %s\n", setup.URL)
|
||||
fmt.Println("резервные коды (каждый одноразовый, сохраните вне сервера):")
|
||||
for _, recovery := range codes {
|
||||
fmt.Printf(" %s\n", recovery)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// cliTOTPReset удаляет секрет 2FA (например, при потере устройства и кодов).
|
||||
func cliTOTPReset(args []string) int {
|
||||
flags := flag.NewFlagSet("totp-reset", flag.ContinueOnError)
|
||||
email := flags.String("email", "", "email пользователя")
|
||||
positional := flags.Args()
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if *email == "" && len(positional) > 0 {
|
||||
*email = positional[0]
|
||||
}
|
||||
if *email == "" {
|
||||
fmt.Fprintln(os.Stderr, "glchat totp-reset: укажите --email")
|
||||
return 2
|
||||
}
|
||||
|
||||
return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error {
|
||||
user, err := authService.UserByEmail(ctx, *email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("пользователь с таким email не найден")
|
||||
}
|
||||
if err := st.DeleteTOTPSecret(ctx, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("секрет 2FA для %s удалён; включите заново командой totp-setup\n", user.Username)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func cliSetInstanceAdmin(command string, args []string) int {
|
||||
flags := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||
email := flags.String("email", "", "email пользователя")
|
||||
|
||||
+3
-2
@@ -37,10 +37,11 @@ func main() {
|
||||
os.Exit(healthcheck())
|
||||
}
|
||||
// Подкоманды обслуживания (AGENT.md 10.7): bootstrap-admin, reset-password,
|
||||
// make-admin, remove-admin. Остальные команды реализует shell-обёртка glchat.
|
||||
// make-admin, remove-admin, totp-setup, totp-reset. Остальные команды
|
||||
// реализует shell-обёртка glchat.
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "bootstrap-admin", "reset-password", "make-admin", "remove-admin":
|
||||
case "bootstrap-admin", "reset-password", "make-admin", "remove-admin", "totp-setup", "totp-reset":
|
||||
os.Exit(runCLI(os.Args[1:]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ PROFILE="${PROFILE:-auto}"
|
||||
INSTANCE_NAME="${INSTANCE_NAME:-glchat}"
|
||||
MAIN_GUILD_NAME="${MAIN_GUILD_NAME:-Главный сервер}"
|
||||
ADMIN_EMAIL="${ADMIN_EMAIL:-}"
|
||||
ENABLE_ADMIN_2FA="${ENABLE_ADMIN_2FA:-0}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
|
||||
REGISTRATION_ENABLED="${REGISTRATION_ENABLED:-true}"
|
||||
TURN_ENABLED="${TURN_ENABLED:-true}"
|
||||
@@ -118,6 +119,9 @@ install.sh — установка glchat (AGENT.md 10).
|
||||
--email <email> email для Let's Encrypt (обязателен, если TLS включён)
|
||||
--admin <email> логин инстанс-администратора
|
||||
--password <password> пароль инстанс-администратора (иначе — интерактивный ввод)
|
||||
--admin-2fa включить 2FA инстанс-админу и напечатать секрет и
|
||||
резервные коды (по умолчанию включается вручную:
|
||||
glchat totp-setup --email <admin>)
|
||||
--instance-name <name> название инстанса (по умолчанию glchat)
|
||||
|
||||
Сеть:
|
||||
@@ -183,6 +187,7 @@ parse_args() {
|
||||
--email) ACME_EMAIL="${2:?}"; shift 2 ;;
|
||||
--admin) ADMIN_EMAIL="${2:?}"; shift 2 ;;
|
||||
--password) ADMIN_PASSWORD="${2:?}"; shift 2 ;;
|
||||
--admin-2fa) ENABLE_ADMIN_2FA=1; shift ;;
|
||||
--instance-name) INSTANCE_NAME="${2:?}"; shift 2 ;;
|
||||
--main-guild) MAIN_GUILD_NAME="${2:?}"; shift 2 ;;
|
||||
--ssh-port) SSH_PORT="${2:?}"; shift 2 ;;
|
||||
@@ -1463,6 +1468,16 @@ bootstrap_admin() {
|
||||
else
|
||||
warn "bootstrap-admin недоступен в этой версии образа (появится в фазе 1); данные сохранены в .env"
|
||||
fi
|
||||
# 2FA обязательна для инстанс-админов (AGENT.md 7.19): секрет и резервные
|
||||
# коды печатает команда totp-setup. По умолчанию не выполняем её, чтобы
|
||||
# секреты не оседали в логах установки.
|
||||
if [ "${ENABLE_ADMIN_2FA:-0}" = "1" ]; then
|
||||
info "включение 2FA для инстанс-админа"
|
||||
docker exec glchat-app /usr/local/bin/glchat totp-setup --email "$ADMIN_EMAIL" \
|
||||
|| warn "не удалось включить 2FA автоматически: выполните glchat totp-setup --email $ADMIN_EMAIL"
|
||||
else
|
||||
info "2FA инстанс-админа включается вручную: glchat totp-setup --email $ADMIN_EMAIL"
|
||||
fi
|
||||
}
|
||||
|
||||
run_smoke() {
|
||||
|
||||
@@ -33,8 +33,8 @@ func newAPIError(err error) apiError {
|
||||
case errors.Is(err, auth.ErrTOTPInvalid):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusBadRequest, "auth.totp_invalid", "invalid two-factor code"
|
||||
case errors.Is(err, auth.ErrInstanceAdminTOTP):
|
||||
candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_required"
|
||||
candidate.Message = "instance administrators must enable two-factor authentication"
|
||||
candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_enrollment_required"
|
||||
candidate.Message = "instance administrator must enable two-factor authentication: run `glchat totp-setup --email <admin>` on the server"
|
||||
case errors.Is(err, auth.ErrSessionExpired):
|
||||
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.session_expired", "session expired"
|
||||
case errors.Is(err, auth.ErrStepUpRequired):
|
||||
|
||||
+4
-81
@@ -1,38 +1,11 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router';
|
||||
import { Suspense } from 'react';
|
||||
import { RouterProvider } from 'react-router';
|
||||
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { router } from '@/router';
|
||||
import '@/i18n';
|
||||
|
||||
const LoginPage = lazy(() => import('@/pages/LoginPage'));
|
||||
const RegisterPage = lazy(() => import('@/pages/RegisterPage'));
|
||||
const OnboardingPage = lazy(() => import('@/pages/OnboardingPage'));
|
||||
const StatusPage = lazy(() => import('@/pages/StatusPage'));
|
||||
const AppLayout = lazy(() => import('@/pages/app/AppLayout'));
|
||||
const GuildView = lazy(() => import('@/pages/app/GuildView'));
|
||||
const NoGuildView = lazy(() => import('@/pages/app/NoGuildView'));
|
||||
const SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout'));
|
||||
const ProfileSettingsPage = lazy(() => import('@/pages/settings/ProfileSettingsPage'));
|
||||
const SecuritySettingsPage = lazy(() => import('@/pages/settings/SecuritySettingsPage'));
|
||||
const AppearanceSettingsPage = lazy(() => import('@/pages/settings/AppearanceSettingsPage'));
|
||||
const InstanceSettingsPage = lazy(() => import('@/pages/settings/InstanceSettingsPage'));
|
||||
|
||||
/** `/` — редирект по состоянию сессии. */
|
||||
function IndexRedirect() {
|
||||
const currentUser = useCurrentUser();
|
||||
if (currentUser.isPending) {
|
||||
return (
|
||||
<div className="flex min-h-full items-center justify-center p-6">
|
||||
<LoadingNotice />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Navigate to={currentUser.isSuccess ? '/app' : '/login'} replace />;
|
||||
}
|
||||
|
||||
/** Общая заглушка на время загрузки ленивых страниц. */
|
||||
/** Заглушка на время загрузки ленивых страниц. */
|
||||
export function RouteFallback() {
|
||||
return (
|
||||
<div className="flex min-h-full items-center justify-center p-6">
|
||||
@@ -41,56 +14,6 @@ export function RouteFallback() {
|
||||
);
|
||||
}
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: '/',
|
||||
children: [
|
||||
{ index: true, element: <IndexRedirect /> },
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'register', element: <RegisterPage /> },
|
||||
{ path: 'status', element: <StatusPage /> },
|
||||
{
|
||||
element: <AuthGuard allowIncompleteOnboarding />,
|
||||
children: [{ path: 'onboarding', element: <OnboardingPage /> }],
|
||||
},
|
||||
{
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{
|
||||
path: 'app',
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="empty" replace /> },
|
||||
{ path: 'empty', element: <NoGuildView /> },
|
||||
{ path: ':guildId', element: <GuildView /> },
|
||||
{ path: ':guildId/:channelId', element: <GuildView /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <AuthGuard allowIncompleteOnboarding />,
|
||||
children: [
|
||||
{
|
||||
path: 'settings',
|
||||
element: <SettingsLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="profile" replace /> },
|
||||
{ path: 'profile', element: <ProfileSettingsPage /> },
|
||||
{ path: 'security', element: <SecuritySettingsPage /> },
|
||||
{ path: 'appearance', element: <AppearanceSettingsPage /> },
|
||||
{ path: 'instance', element: <InstanceSettingsPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const router = createBrowserRouter(routes);
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
|
||||
+15
-4
@@ -1,3 +1,5 @@
|
||||
import { describeUnknown } from '@/lib/http';
|
||||
|
||||
import type { Channel, Role, UserStatus } from './types';
|
||||
|
||||
/**
|
||||
@@ -96,11 +98,16 @@ export interface GatewayConnectionOptions {
|
||||
/** Инъекция для тестов; по умолчанию берётся глобальный WebSocket. */
|
||||
socketFactory?: (url: string) => WebSocket;
|
||||
logger?: GatewayLogger;
|
||||
/** Базовая задержка переподключения; в тестах её уменьшают. */
|
||||
baseBackoffMs?: number;
|
||||
/** Сколько ждать HELLO после открытия сокета. */
|
||||
helloTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_HEARTBEAT_MS = 45_000;
|
||||
const MAX_BACKOFF_MS = 30_000;
|
||||
const BASE_BACKOFF_MS = 1_000;
|
||||
const HELLO_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface GatewayPacket {
|
||||
op: number;
|
||||
@@ -326,6 +333,8 @@ export class GatewayConnection {
|
||||
private readonly handlers: GatewayHandlers;
|
||||
private readonly socketFactory: (url: string) => WebSocket;
|
||||
private readonly logger: GatewayLogger;
|
||||
private readonly baseBackoffMs: number;
|
||||
private readonly helloTimeoutMs: number;
|
||||
|
||||
private socket: WebSocket | null = null;
|
||||
private status: GatewayStatus = 'idle';
|
||||
@@ -343,6 +352,8 @@ export class GatewayConnection {
|
||||
this.url = options.url;
|
||||
this.handlers = options.handlers ?? {};
|
||||
this.logger = options.logger ?? { warn: () => undefined, error: () => undefined };
|
||||
this.baseBackoffMs = options.baseBackoffMs ?? BASE_BACKOFF_MS;
|
||||
this.helloTimeoutMs = options.helloTimeoutMs ?? HELLO_TIMEOUT_MS;
|
||||
this.socketFactory =
|
||||
options.socketFactory ??
|
||||
((url) => {
|
||||
@@ -403,13 +414,13 @@ export class GatewayConnection {
|
||||
try {
|
||||
socket = this.socketFactory(this.url);
|
||||
} catch (error) {
|
||||
this.logger.warn(`gateway: cannot open socket (${String(error)})`);
|
||||
this.logger.warn(`gateway: cannot open socket (${describeUnknown(error)})`);
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this.socket = socket;
|
||||
if (reason !== undefined) {
|
||||
this.logger.warn(`gateway: reconnecting (${String(reason)})`);
|
||||
this.logger.warn(`gateway: reconnecting (${describeUnknown(reason)})`);
|
||||
}
|
||||
|
||||
socket.onopen = () => {
|
||||
@@ -417,7 +428,7 @@ export class GatewayConnection {
|
||||
this.helloTimeout = setTimeout(() => {
|
||||
this.logger.warn('gateway: HELLO timeout');
|
||||
this.dropSocket();
|
||||
}, 15_000);
|
||||
}, this.helloTimeoutMs);
|
||||
};
|
||||
|
||||
socket.onmessage = (event: MessageEvent) => {
|
||||
@@ -464,7 +475,7 @@ export class GatewayConnection {
|
||||
return;
|
||||
}
|
||||
this.attempt += 1;
|
||||
const backoff = Math.min(BASE_BACKOFF_MS * 2 ** (this.attempt - 1), MAX_BACKOFF_MS);
|
||||
const backoff = Math.min(this.baseBackoffMs * 2 ** (this.attempt - 1), MAX_BACKOFF_MS);
|
||||
const jitter = Math.floor(Math.random() * 250);
|
||||
this.setStatus('reconnecting');
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Navigate, Outlet, useLocation } from 'react-router';
|
||||
|
||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { isUnauthorized } from '@/lib/http';
|
||||
|
||||
interface AuthGuardProps {
|
||||
/**
|
||||
@@ -45,18 +46,3 @@ export function AuthGuard({ allowIncompleteOnboarding = false }: AuthGuardProps)
|
||||
|
||||
return <Outlet context={{ user }} />;
|
||||
}
|
||||
|
||||
/** Коды, которые означают именно отсутствие сессии. */
|
||||
const unauthorizedCodes = new Set(['auth.unauthorized', 'auth.session_invalid', 'auth.required']);
|
||||
|
||||
/** 401/403 или один из «сессионных» кодов; остальное — сеть или ошибка сервера. */
|
||||
export function isUnauthorized(error: unknown): boolean {
|
||||
if (typeof error !== 'object' || error === null) {
|
||||
return false;
|
||||
}
|
||||
const candidate = error as { status?: unknown; code?: unknown };
|
||||
if (candidate.status === 401) {
|
||||
return true;
|
||||
}
|
||||
return typeof candidate.code === 'string' && unauthorizedCodes.has(candidate.code);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Channel } from '@/api/types';
|
||||
|
||||
export interface ChannelGroup {
|
||||
category: Channel | null;
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Группирует комнаты по категориям, сохраняя порядок `position`.
|
||||
* Невидимые комнаты (`can_view === false`) сервер обычно не присылает,
|
||||
* но если прислал — прячем их на клиенте.
|
||||
*/
|
||||
export function groupChannels(channels: Channel[]): ChannelGroup[] {
|
||||
const visible = channels
|
||||
.filter((channel) => channel.can_view !== false && channel.type !== 'category')
|
||||
.slice()
|
||||
.sort((left, right) => left.position - right.position);
|
||||
const categories = channels
|
||||
.filter((channel) => channel.type === 'category')
|
||||
.slice()
|
||||
.sort((left, right) => left.position - right.position);
|
||||
|
||||
const groups: ChannelGroup[] = [];
|
||||
const uncategorized = visible.filter((channel) => channel.parent_id === undefined);
|
||||
if (uncategorized.length > 0) {
|
||||
groups.push({ category: null, channels: uncategorized });
|
||||
}
|
||||
for (const category of categories) {
|
||||
const children = visible.filter((channel) => channel.parent_id === category.id);
|
||||
if (children.length > 0) {
|
||||
groups.push({ category, channels: children });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Коды, которые означают именно отсутствие сессии. */
|
||||
const unauthorizedCodes = new Set(['auth.unauthorized', 'auth.session_invalid', 'auth.required']);
|
||||
|
||||
/** 401 или один из «сессионных» кодов; остальное — сеть или ошибка сервера. */
|
||||
export function isUnauthorized(error: unknown): boolean {
|
||||
if (typeof error !== 'object' || error === null) {
|
||||
return false;
|
||||
}
|
||||
const candidate = error as { status?: unknown; code?: unknown };
|
||||
if (candidate.status === 401) {
|
||||
return true;
|
||||
}
|
||||
return typeof candidate.code === 'string' && unauthorizedCodes.has(candidate.code);
|
||||
}
|
||||
|
||||
/** Есть ли у объекта осмысленное строковое описание ошибки. */
|
||||
export function describeUnknown(value: unknown): string {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return 'unknown error';
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Navigate } from 'react-router';
|
||||
|
||||
import { LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
|
||||
/** `/` — редирект по состоянию сессии: в приложение или на вход. */
|
||||
export default function IndexRedirect() {
|
||||
const currentUser = useCurrentUser();
|
||||
if (currentUser.isPending) {
|
||||
return (
|
||||
<div className="flex min-h-full items-center justify-center p-6">
|
||||
<LoadingNotice />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Navigate to={currentUser.isSuccess ? '/app' : '/login'} replace />;
|
||||
}
|
||||
@@ -1,50 +1,35 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { completeOnboarding, currentUserQueryKey } from '@/api/users';
|
||||
import type { OnboardingInput } from '@/api/users';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useUiStore, type Theme } from '@/stores/ui';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
|
||||
/** Первичная настройка: имя, «о себе», язык и тема, затем POST onboarding/complete. */
|
||||
export default function OnboardingPage() {
|
||||
interface OnboardingFormProps {
|
||||
/** Имя, предложенное сервером (или из снапшота шлюза). */
|
||||
suggestedName: string;
|
||||
preferredLocale: SupportedLanguage;
|
||||
}
|
||||
|
||||
/** Форма онбординга: имя, «о себе», язык и тема + POST onboarding/complete. */
|
||||
function OnboardingForm({ suggestedName, preferredLocale }: OnboardingFormProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const currentUser = useCurrentUser();
|
||||
const theme = useUiStore((state) => state.theme);
|
||||
const setTheme = useUiStore((state) => state.setTheme);
|
||||
const sessionUser = useSessionStore((state) => state.user);
|
||||
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [displayName, setDisplayName] = useState(suggestedName);
|
||||
const [bio, setBio] = useState('');
|
||||
const [locale, setLocale] = useState<SupportedLanguage>(
|
||||
i18n.resolvedLanguage === 'en' ? 'en' : 'ru',
|
||||
);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
const user = currentUser.data;
|
||||
const suggestedName = user?.display_name ?? sessionUser?.display_name ?? user?.username ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (suggestedName !== '' && !touched) {
|
||||
setDisplayName((current) => (current === '' ? suggestedName : current));
|
||||
}
|
||||
}, [suggestedName, touched]);
|
||||
|
||||
useEffect(() => {
|
||||
const preferred = user?.locale;
|
||||
if (preferred === 'en' || preferred === 'ru') {
|
||||
setLocale(preferred);
|
||||
}
|
||||
}, [user?.locale]);
|
||||
const [locale, setLocale] = useState<SupportedLanguage>(preferredLocale);
|
||||
|
||||
const complete = useMutation({
|
||||
mutationFn: (input: OnboardingInput) => completeOnboarding(input),
|
||||
@@ -61,13 +46,8 @@ export default function OnboardingPage() {
|
||||
void i18n.changeLanguage(language);
|
||||
};
|
||||
|
||||
const changeTheme = (next: Theme): void => {
|
||||
setTheme(next);
|
||||
};
|
||||
|
||||
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
setTouched(true);
|
||||
const input: OnboardingInput = { locale };
|
||||
const trimmedName = displayName.trim();
|
||||
const trimmedBio = bio.trim();
|
||||
@@ -80,19 +60,7 @@ export default function OnboardingPage() {
|
||||
complete.mutate(input);
|
||||
};
|
||||
|
||||
const unknownUser = currentUser.isError && !complete.isSuccess;
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col justify-center gap-5 px-4 py-10">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{suggestedName === ''
|
||||
? t('onboarding.title')
|
||||
: t('onboarding.greeting', { name: suggestedName })}
|
||||
</h1>
|
||||
<p className="mt-1 text-fg-muted">{t('onboarding.subtitle')}</p>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
|
||||
<Field
|
||||
@@ -100,10 +68,7 @@ export default function OnboardingPage() {
|
||||
hint={t('onboarding.displayNameHint')}
|
||||
name="display_name"
|
||||
value={displayName}
|
||||
onChange={(event) => {
|
||||
setTouched(true);
|
||||
setDisplayName(event.target.value);
|
||||
}}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
maxLength={64}
|
||||
/>
|
||||
<TextAreaField
|
||||
@@ -127,14 +92,13 @@ export default function OnboardingPage() {
|
||||
<SelectField
|
||||
label={t('onboarding.theme')}
|
||||
value={theme}
|
||||
onChange={(value) => changeTheme(value === 'light' ? 'light' : 'dark')}
|
||||
onChange={(value) => setTheme(value === 'light' ? 'light' : 'dark')}
|
||||
options={[
|
||||
{ value: 'dark', label: t('theme.dark') },
|
||||
{ value: 'light', label: t('theme.light') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{unknownUser ? <ErrorNotice error={currentUser.error} /> : null}
|
||||
{complete.isError ? <ErrorNotice error={complete.error} /> : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -145,6 +109,41 @@ export default function OnboardingPage() {
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Первичная настройка: приветствие, имя, «о себе», язык и тема. */
|
||||
export default function OnboardingPage() {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const sessionUser = useSessionStore((state) => state.user);
|
||||
|
||||
const user = currentUser.data;
|
||||
const suggestedName = user?.display_name ?? sessionUser?.display_name ?? user?.username ?? '';
|
||||
const preferredLocale: SupportedLanguage = user?.locale === 'en' ? 'en' : 'ru';
|
||||
|
||||
if (currentUser.isPending) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col justify-center gap-5 px-4 py-10">
|
||||
<LoadingNotice />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col justify-center gap-5 px-4 py-10">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{suggestedName === ''
|
||||
? t('onboarding.title')
|
||||
: t('onboarding.greeting', { name: suggestedName })}
|
||||
</h1>
|
||||
<p className="mt-1 text-fg-muted">{t('onboarding.subtitle')}</p>
|
||||
</header>
|
||||
|
||||
<OnboardingForm suggestedName={suggestedName} preferredLocale={preferredLocale} />
|
||||
|
||||
{currentUser.isError ? <ErrorNotice error={currentUser.error} /> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ export default function AppLayout() {
|
||||
selectChannel(guildId, channelId);
|
||||
}, [guildId, channelId, selectedGuildId, selectChannel]);
|
||||
|
||||
// `/app/empty` — служебный маршрут, а не идентификатор сервера.
|
||||
const realGuildId = guildId === 'empty' ? null : guildId;
|
||||
|
||||
const myGuilds = useQuery({
|
||||
queryKey: myGuildsQueryKey,
|
||||
queryFn: ({ signal }) => fetchMyGuilds(signal),
|
||||
@@ -65,11 +68,11 @@ export default function AppLayout() {
|
||||
}
|
||||
}, [guilds.length, myGuilds.data, upsertGuild]);
|
||||
|
||||
const guildInSnapshot = guildId !== null && guilds.some((guild) => guild.id === guildId);
|
||||
const guildInSnapshot = realGuildId !== null && guilds.some((guild) => guild.id === realGuildId);
|
||||
const fallbackGuild = useQuery({
|
||||
queryKey: guildQueryKey(guildId ?? ''),
|
||||
queryFn: ({ signal }) => fetchGuild(guildId ?? '', signal),
|
||||
enabled: guildId !== null && !guildInSnapshot,
|
||||
queryKey: guildQueryKey(realGuildId ?? ''),
|
||||
queryFn: ({ signal }) => fetchGuild(realGuildId ?? '', signal),
|
||||
enabled: realGuildId !== null && !guildInSnapshot,
|
||||
retry: 0,
|
||||
});
|
||||
const hydrated = useRef<string | null>(null);
|
||||
|
||||
@@ -9,46 +9,16 @@ import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field, SelectField } from '@/components/ui/Field';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
import { groupChannels } from '@/lib/channels';
|
||||
import { canManageGuild } from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import type { Channel } from '@/api/types';
|
||||
|
||||
interface ChannelSidebarProps {
|
||||
guildId: string | null;
|
||||
channelId: string | null;
|
||||
}
|
||||
|
||||
interface ChannelGroup {
|
||||
category: Channel | null;
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
/** Группирует комнаты по категориям, сохраняя порядок `position`. */
|
||||
export function groupChannels(channels: Channel[]): ChannelGroup[] {
|
||||
const visible = channels
|
||||
.filter((channel) => channel.can_view !== false && channel.type !== 'category')
|
||||
.slice()
|
||||
.sort((left, right) => left.position - right.position);
|
||||
const categories = channels
|
||||
.filter((channel) => channel.type === 'category')
|
||||
.slice()
|
||||
.sort((left, right) => left.position - right.position);
|
||||
|
||||
const groups: ChannelGroup[] = [];
|
||||
const uncategorized = visible.filter((channel) => channel.parent_id === undefined);
|
||||
if (uncategorized.length > 0) {
|
||||
groups.push({ category: null, channels: uncategorized });
|
||||
}
|
||||
for (const category of categories) {
|
||||
const children = visible.filter((channel) => channel.parent_id === category.id);
|
||||
if (children.length > 0) {
|
||||
groups.push({ category, channels: children });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Сайдбар комнат выбранного сервера: категории, текстовые и голосовые комнаты. */
|
||||
export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -68,7 +38,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
||||
const [channelType, setChannelType] = useState<'text' | 'voice'>('text');
|
||||
const [parentId, setParentId] = useState('');
|
||||
|
||||
const channels = guild?.channels ?? [];
|
||||
const channels = useMemo(() => guild?.channels ?? [], [guild]);
|
||||
const needsChannels = guildId !== null && guild !== null && channels.length === 0;
|
||||
|
||||
const remoteChannels = useQuery({
|
||||
@@ -115,7 +85,10 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
||||
});
|
||||
|
||||
const groups = useMemo(() => groupChannels(channels), [channels]);
|
||||
const categories = channels.filter((channel) => channel.type === 'category');
|
||||
const categories = useMemo(
|
||||
() => channels.filter((channel) => channel.type === 'category'),
|
||||
[channels],
|
||||
);
|
||||
const canManage =
|
||||
guild !== null && canManageGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id);
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
/**
|
||||
* Ленивые страницы приложения. Лежат отдельно от таблицы маршрутов, чтобы
|
||||
* модуль с `routes`/`router` не экспортировал компоненты (react-refresh).
|
||||
*/
|
||||
export const IndexRedirect = lazy(() => import('@/pages/IndexRedirect'));
|
||||
export const LoginPage = lazy(() => import('@/pages/LoginPage'));
|
||||
export const RegisterPage = lazy(() => import('@/pages/RegisterPage'));
|
||||
export const OnboardingPage = lazy(() => import('@/pages/OnboardingPage'));
|
||||
export const StatusPage = lazy(() => import('@/pages/StatusPage'));
|
||||
export const AppLayout = lazy(() => import('@/pages/app/AppLayout'));
|
||||
export const GuildView = lazy(() => import('@/pages/app/GuildView'));
|
||||
export const NoGuildView = lazy(() => import('@/pages/app/NoGuildView'));
|
||||
export const SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout'));
|
||||
export const ProfileSettingsPage = lazy(() => import('@/pages/settings/ProfileSettingsPage'));
|
||||
export const SecuritySettingsPage = lazy(() => import('@/pages/settings/SecuritySettingsPage'));
|
||||
export const AppearanceSettingsPage = lazy(() => import('@/pages/settings/AppearanceSettingsPage'));
|
||||
export const InstanceSettingsPage = lazy(() => import('@/pages/settings/InstanceSettingsPage'));
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { SelectField } from '@/components/ui/Field';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||
import { useUiStore, type Theme } from '@/stores/ui';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
|
||||
/** Внешний вид: тема и язык. Хранятся локально, без запросов к серверу. */
|
||||
export default function AppearanceSettingsPage() {
|
||||
@@ -27,7 +27,7 @@ export default function AppearanceSettingsPage() {
|
||||
label={t('settings.appearance.theme')}
|
||||
hint={t('settings.appearance.themeHint')}
|
||||
value={theme}
|
||||
onChange={(value) => setTheme(value === 'light' ? 'light' : ('dark' as Theme))}
|
||||
onChange={(value) => setTheme(value === 'light' ? 'light' : 'dark')}
|
||||
options={[
|
||||
{ value: 'dark', label: t('theme.dark') },
|
||||
{ value: 'light', label: t('theme.light') },
|
||||
|
||||
@@ -1,44 +1,33 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { updateProfile, currentUserQueryKey, type UpdateProfileInput } from '@/api/users';
|
||||
import { userStatuses, type User } from '@/api/types';
|
||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { userStatuses } from '@/api/types';
|
||||
|
||||
/** Профиль: отображаемое имя, «о себе», своё состояние, статус и язык. */
|
||||
export default function ProfileSettingsPage() {
|
||||
/**
|
||||
* Форма профиля. Состояние инициализируется из `user` при монтировании,
|
||||
* а после сохранения кэш React Query обновляется ответом сервера — поэтому
|
||||
* синхронизация через эффект не нужна.
|
||||
*/
|
||||
function ProfileForm({ user }: { user: User }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [bio, setBio] = useState('');
|
||||
const [customStatus, setCustomStatus] = useState('');
|
||||
const [customEmoji, setCustomEmoji] = useState('');
|
||||
const [status, setStatus] = useState<string>('online');
|
||||
const [locale, setLocale] = useState<SupportedLanguage>('ru');
|
||||
const [displayName, setDisplayName] = useState(user.display_name);
|
||||
const [bio, setBio] = useState(user.bio);
|
||||
const [customStatus, setCustomStatus] = useState(user.custom_status);
|
||||
const [customEmoji, setCustomEmoji] = useState(user.custom_status_emoji);
|
||||
const [status, setStatus] = useState<string>(user.status);
|
||||
const [locale, setLocale] = useState<SupportedLanguage>(user.locale === 'en' ? 'en' : 'ru');
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const user = currentUser.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (user === undefined) {
|
||||
return;
|
||||
}
|
||||
setDisplayName(user.display_name);
|
||||
setBio(user.bio);
|
||||
setCustomStatus(user.custom_status);
|
||||
setCustomEmoji(user.custom_status_emoji);
|
||||
setStatus(user.status);
|
||||
setLocale(user.locale === 'en' ? 'en' : 'ru');
|
||||
}, [user]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (input: UpdateProfileInput) => updateProfile(input),
|
||||
onSuccess: (updated) => {
|
||||
@@ -65,22 +54,6 @@ export default function ProfileSettingsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
if (currentUser.isPending) {
|
||||
return (
|
||||
<Card>
|
||||
<LoadingNotice />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentUser.isError || user === undefined) {
|
||||
return (
|
||||
<Card>
|
||||
<ErrorNotice error={currentUser.error} onRetry={() => void currentUser.refetch()} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold">{t('settings.profile.title')}</h2>
|
||||
@@ -173,3 +146,34 @@ export default function ProfileSettingsPage() {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Вкладка «Профиль»: ждёт загрузки профиля и передаёт данные форме. */
|
||||
export default function ProfileSettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
if (currentUser.isPending) {
|
||||
return (
|
||||
<Card>
|
||||
<LoadingNotice />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentUser.data === undefined) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-sm text-fg-muted">{t('common.loading')}</p>
|
||||
{currentUser.isError ? (
|
||||
<ErrorNotice
|
||||
className="mt-3"
|
||||
error={currentUser.error}
|
||||
onRetry={() => void currentUser.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProfileForm key={currentUser.data.id} user={currentUser.data} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router';
|
||||
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import {
|
||||
AppearanceSettingsPage,
|
||||
AppLayout,
|
||||
IndexRedirect,
|
||||
GuildView,
|
||||
InstanceSettingsPage,
|
||||
LoginPage,
|
||||
NoGuildView,
|
||||
OnboardingPage,
|
||||
ProfileSettingsPage,
|
||||
RegisterPage,
|
||||
SecuritySettingsPage,
|
||||
SettingsLayout,
|
||||
StatusPage,
|
||||
} from '@/pages/lazyPages';
|
||||
|
||||
/** Таблица маршрутов: публичные страницы, защищённые и настройки. */
|
||||
export const routes = [
|
||||
{
|
||||
path: '/',
|
||||
children: [
|
||||
{ index: true, element: <IndexRedirect /> },
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'register', element: <RegisterPage /> },
|
||||
{ path: 'status', element: <StatusPage /> },
|
||||
{
|
||||
element: <AuthGuard allowIncompleteOnboarding />,
|
||||
children: [{ path: 'onboarding', element: <OnboardingPage /> }],
|
||||
},
|
||||
{
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{
|
||||
path: 'app',
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="empty" replace /> },
|
||||
{ path: 'empty', element: <NoGuildView /> },
|
||||
{ path: ':guildId', element: <GuildView /> },
|
||||
{ path: ':guildId/:channelId', element: <GuildView /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <AuthGuard allowIncompleteOnboarding />,
|
||||
children: [
|
||||
{
|
||||
path: 'settings',
|
||||
element: <SettingsLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="profile" replace /> },
|
||||
{ path: 'profile', element: <ProfileSettingsPage /> },
|
||||
{ path: 'security', element: <SecuritySettingsPage /> },
|
||||
{ path: 'appearance', element: <AppearanceSettingsPage /> },
|
||||
{ path: 'instance', element: <InstanceSettingsPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const router = createBrowserRouter(routes);
|
||||
@@ -0,0 +1,191 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
GatewayConnection,
|
||||
GatewayOp,
|
||||
parseGatewaySnapshot,
|
||||
type GatewayDispatch,
|
||||
type GatewayStatus,
|
||||
} from '@/api/gateway';
|
||||
import { makeReadySnapshot } from './helpers';
|
||||
|
||||
/** Подставной WebSocket: рукопожатие и heartbeat полностью под контролем теста. */
|
||||
class FakeSocket {
|
||||
static instances: FakeSocket[] = [];
|
||||
|
||||
readonly url: string;
|
||||
readyState = 0;
|
||||
sent: string[] = [];
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
onclose: ((event: CloseEvent) => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
FakeSocket.instances.push(this);
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = 1;
|
||||
this.onopen?.(new Event('open'));
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = 3;
|
||||
}
|
||||
|
||||
/** Присылает кадр от сервера. */
|
||||
emit(packet: unknown): void {
|
||||
this.onmessage?.({ data: JSON.stringify(packet) } as MessageEvent);
|
||||
}
|
||||
|
||||
/** Последний отправленный пакет в виде объекта. */
|
||||
lastSent(): { op: number; d: unknown } {
|
||||
const raw = this.sent.at(-1);
|
||||
if (raw === undefined) {
|
||||
throw new Error('socket has not sent anything');
|
||||
}
|
||||
return JSON.parse(raw) as { op: number; d: unknown };
|
||||
}
|
||||
|
||||
closeFromServer(code = 1006): void {
|
||||
this.readyState = 3;
|
||||
this.onclose?.({ code } as CloseEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function makeConnection(handlers: {
|
||||
onDispatch?: (event: GatewayDispatch) => void;
|
||||
onStatus?: (status: GatewayStatus) => void;
|
||||
onInvalidSession?: () => void;
|
||||
onResumeIncomplete?: () => void;
|
||||
}) {
|
||||
FakeSocket.instances = [];
|
||||
const connection = new GatewayConnection({
|
||||
url: 'ws://localhost/gateway',
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
handlers,
|
||||
// Быстрый backoff, чтобы тест переподключения не ждал секунду.
|
||||
baseBackoffMs: 5,
|
||||
helloTimeoutMs: 60_000,
|
||||
});
|
||||
connection.connect();
|
||||
connections.push(connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
const connections: GatewayConnection[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
// Закрываем соединения, иначе таймеры heartbeat продолжают жить между тестами.
|
||||
for (const connection of connections.splice(0)) {
|
||||
connection.close();
|
||||
}
|
||||
FakeSocket.instances = [];
|
||||
});
|
||||
|
||||
describe('gateway client', () => {
|
||||
it('answers HELLO with IDENTIFY and an empty token', () => {
|
||||
const connection = makeConnection({});
|
||||
const socket = FakeSocket.instances[0];
|
||||
expect(socket).toBeDefined();
|
||||
socket?.open();
|
||||
|
||||
socket?.emit({
|
||||
op: GatewayOp.HELLO,
|
||||
d: { heartbeat_interval_ms: 1000, session_id: 'sess-1' },
|
||||
});
|
||||
|
||||
expect(socket?.lastSent()).toEqual({ op: GatewayOp.IDENTIFY, d: { token: '', resume_seq: 0 } });
|
||||
expect(connection.getStatus()).toBe('connecting');
|
||||
connection.close();
|
||||
});
|
||||
|
||||
it('delivers READY to the dispatch handler', () => {
|
||||
const events: GatewayDispatch[] = [];
|
||||
const connection = makeConnection({ onDispatch: (event) => events.push(event) });
|
||||
const socket = FakeSocket.instances[0];
|
||||
socket?.open();
|
||||
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
|
||||
socket?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 1, d: makeReadySnapshot([
|
||||
{ id: 'g-1', name: 'Main', channels: [{ id: 'c-1', name: 'general' }] },
|
||||
]) });
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.t).toBe('READY');
|
||||
expect(connection.getStatus()).toBe('connected');
|
||||
expect(connection.getLastSeq()).toBe(1);
|
||||
|
||||
const snapshot = parseGatewaySnapshot(events[0]?.d);
|
||||
expect(snapshot?.guilds[0]?.channels[0]?.name).toBe('general');
|
||||
connection.close();
|
||||
});
|
||||
|
||||
it('ignores unknown events and unparsable frames', () => {
|
||||
const events: GatewayDispatch[] = [];
|
||||
const connection = makeConnection({ onDispatch: (event) => events.push(event) });
|
||||
const socket = FakeSocket.instances[0];
|
||||
socket?.open();
|
||||
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
|
||||
socket?.emit({ op: GatewayOp.DISPATCH, t: 'GUILD_CREATE', s: 2, d: { id: 'g-9' } });
|
||||
socket?.emit({ op: 99, d: null });
|
||||
socket?.onmessage?.({ data: 'not json' } as MessageEvent);
|
||||
|
||||
expect(events.map((event) => event.t)).toEqual(['GUILD_CREATE']);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
it('sends HEARTBEAT with the interval from HELLO and tracks the ack', async () => {
|
||||
const connection = makeConnection({});
|
||||
const socket = FakeSocket.instances[0];
|
||||
socket?.open();
|
||||
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 20, session_id: 's' } });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 45));
|
||||
const heartbeat = socket?.lastSent();
|
||||
expect(heartbeat).toEqual({ op: GatewayOp.HEARTBEAT, d: null });
|
||||
|
||||
socket?.emit({ op: GatewayOp.HEARTBEAT_ACK, d: null });
|
||||
connection.close();
|
||||
});
|
||||
|
||||
it('reconnects with RESUME and the last sequence number', async () => {
|
||||
const statuses: GatewayStatus[] = [];
|
||||
const connection = makeConnection({ onStatus: (status) => statuses.push(status) });
|
||||
const first = FakeSocket.instances[0];
|
||||
first?.open();
|
||||
first?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 'sess-7' } });
|
||||
first?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 42, d: makeReadySnapshot([]) });
|
||||
|
||||
first?.closeFromServer();
|
||||
expect(connection.getStatus()).toBe('reconnecting');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const second = FakeSocket.instances[1];
|
||||
expect(second).toBeDefined();
|
||||
second?.open();
|
||||
second?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 'sess-7' } });
|
||||
|
||||
expect(second?.lastSent()).toEqual({ op: GatewayOp.RESUME, d: { token: '', resume_seq: 42 } });
|
||||
expect(statuses).toContain('reconnecting');
|
||||
connection.close();
|
||||
});
|
||||
|
||||
it('reports INVALID_SESSION to the handler', () => {
|
||||
let invalidated = 0;
|
||||
const connection = makeConnection({ onInvalidSession: () => (invalidated += 1) });
|
||||
const socket = FakeSocket.instances[0];
|
||||
socket?.open();
|
||||
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
|
||||
socket?.emit({ op: GatewayOp.INVALID_SESSION, d: false });
|
||||
|
||||
expect(invalidated).toBe(1);
|
||||
expect(connection.getStatus()).toBe('disconnected');
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { routes } from '@/router';
|
||||
import { setQueryClient } from '@/lib/queryClient';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import type { User } from '@/api/types';
|
||||
|
||||
/** Ответ-заглушка с JSON-телом (как у настоящего API). */
|
||||
export function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Ошибка в формате контракта: 4xx + `{"error":{"code","message"}}`. */
|
||||
export function apiError(code: string, status = 400, message = 'error'): Response {
|
||||
return json({ error: { code, message } }, status);
|
||||
}
|
||||
|
||||
export interface FetchRoute {
|
||||
/** Подстрока URL, по которой выбирается обработчик. */
|
||||
match: string;
|
||||
method?: string;
|
||||
response: () => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Мок fetch по таблице маршрутов. Метод сравнивается строго (по умолчанию GET),
|
||||
* URL — по вхождению подстроки. Более длинные шаблоны проверяются раньше,
|
||||
* поэтому `/users/@me/guilds` не перехватывается записью `/users/@me`.
|
||||
*/
|
||||
export function installFetch(routes: FetchRoute[]) {
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
||||
const ordered = [...routes].sort((left, right) => right.match.length - left.match.length);
|
||||
for (const route of ordered) {
|
||||
if ((route.method ?? 'GET').toUpperCase() !== method) {
|
||||
continue;
|
||||
}
|
||||
if (!url.includes(route.match)) {
|
||||
continue;
|
||||
}
|
||||
return Promise.resolve(route.response());
|
||||
}
|
||||
return Promise.resolve(apiError('not_found', 404));
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
/** Мок fetch, который падает на уровне сети (сервер недоступен). */
|
||||
export function installFailingFetch() {
|
||||
const fetchMock = vi.fn(() => Promise.reject(new TypeError('Failed to fetch')));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
export function createTestQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Рендер приложения на заданном маршруте с изолированными сторами. */
|
||||
export function renderApp(initialPath = '/') {
|
||||
const queryClient = createTestQueryClient();
|
||||
// Стор шлюза обращается к общему клиенту (INVALID_SESSION → сброс кэша),
|
||||
// поэтому подменяем его на тестовый.
|
||||
setQueryClient(queryClient);
|
||||
useSessionStore.getState().reset();
|
||||
useGatewayStore.setState({
|
||||
status: 'idle',
|
||||
heartbeatIntervalMs: null,
|
||||
sessionId: null,
|
||||
attempts: 0,
|
||||
invalidated: false,
|
||||
});
|
||||
|
||||
const router = createMemoryRouter(routes, { initialEntries: [initialPath] });
|
||||
const view = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...view, router, queryClient };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ждёт, пока отрисуется ленивая страница: react-router подгружает её
|
||||
* асинхронно, поэтому сразу после render DOM ещё пуст.
|
||||
*/
|
||||
export async function waitForPage(anchor = 'glchat') {
|
||||
await waitFor(() => {
|
||||
expect(document.body.textContent ?? '').toContain(anchor);
|
||||
});
|
||||
}
|
||||
|
||||
/** Ждёт появления поля по label и возвращает его. */
|
||||
export async function findField(label: string): Promise<HTMLElement> {
|
||||
return screen.findByLabelText(label);
|
||||
}
|
||||
|
||||
/** Полный профиль пользователя для мока `GET /users/@me`. */
|
||||
export function makeUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
display_name: 'Alice',
|
||||
bio: '',
|
||||
status: 'online',
|
||||
custom_status: '',
|
||||
custom_status_emoji: '',
|
||||
is_instance_admin: false,
|
||||
badges: [],
|
||||
locale: 'ru',
|
||||
onboarding_completed: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface SnapshotGuildInput {
|
||||
id: string;
|
||||
name: string;
|
||||
is_main?: boolean;
|
||||
channels?: {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: 'text' | 'voice' | 'category';
|
||||
position?: number;
|
||||
parent_id?: string;
|
||||
can_send?: boolean;
|
||||
can_view?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** Снапшот READY в формате шлюза. */
|
||||
export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
|
||||
return {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
display_name: 'Alice',
|
||||
is_instance_admin: false,
|
||||
badges: [],
|
||||
},
|
||||
guilds: guilds.map((guild) => ({
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
owner_id: 'user-1',
|
||||
is_main: guild.is_main ?? false,
|
||||
channels: (guild.channels ?? []).map((channel, index) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type ?? 'text',
|
||||
position: channel.position ?? index,
|
||||
...(channel.parent_id === undefined ? {} : { parent_id: channel.parent_id }),
|
||||
...(channel.can_send === undefined ? {} : { can_send: channel.can_send }),
|
||||
...(channel.can_view === undefined ? {} : { can_view: channel.can_view }),
|
||||
})),
|
||||
roles: [],
|
||||
member_ids: ['user-1'],
|
||||
my_role_ids: [],
|
||||
my_permissions: [],
|
||||
})),
|
||||
dm_channels: [],
|
||||
read_states: [],
|
||||
session_id: 'session-1',
|
||||
heartbeat_interval_ms: 45_000,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { apiError, installFetch, json, makeUser, renderApp } from './helpers';
|
||||
|
||||
const instanceOpen = {
|
||||
name: 'glchat-test',
|
||||
version: 'v0.1.0-test',
|
||||
registration_enabled: true,
|
||||
allow_guild_creation: true,
|
||||
max_guilds_per_user: 5,
|
||||
max_members_per_guild: 100,
|
||||
max_message_length: 2000,
|
||||
main_guild_id: 'g-main',
|
||||
user_count: 1,
|
||||
guild_count: 1,
|
||||
};
|
||||
|
||||
describe('страница входа', () => {
|
||||
it('успешный вход переводит в /app', async () => {
|
||||
const user = makeUser();
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
||||
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user }) },
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
{ match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) },
|
||||
]);
|
||||
|
||||
const { router } = renderApp('/login');
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
|
||||
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/app/empty');
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
|
||||
).toBeVisible();
|
||||
|
||||
const loginCall = fetchMock.mock.calls.find(([input]) =>
|
||||
String(input).includes('/auth/login'),
|
||||
);
|
||||
expect(loginCall).toBeDefined();
|
||||
const init = loginCall?.[1] as RequestInit;
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
email: 'alice@example.com',
|
||||
password: 'super-secret-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('при auth.2fa_required показывает поле кода и повторяет вход с totp_code', async () => {
|
||||
const user = makeUser();
|
||||
let loginAttempts = 0;
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
||||
{
|
||||
match: '/api/v1/auth/login',
|
||||
method: 'POST',
|
||||
response: () => {
|
||||
loginAttempts += 1;
|
||||
return loginAttempts === 1 ? apiError('auth.2fa_required', 401) : json({ user });
|
||||
},
|
||||
},
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
]);
|
||||
|
||||
const { router } = renderApp('/login');
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
|
||||
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
|
||||
const totpField = await screen.findByLabelText('Код 2FA');
|
||||
expect(totpField).toBeVisible();
|
||||
|
||||
await visitor.type(totpField, '123456');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/app/empty');
|
||||
});
|
||||
|
||||
const secondLogin = fetchMock.mock.calls.filter(([input]) =>
|
||||
String(input).includes('/auth/login'),
|
||||
)[1];
|
||||
const init = secondLogin?.[1] as RequestInit;
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
email: 'alice@example.com',
|
||||
password: 'super-secret-1',
|
||||
totp_code: '123456',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { installFetch, json, makeUser, renderApp } from './helpers';
|
||||
|
||||
const instanceBase = {
|
||||
name: 'glchat-test',
|
||||
version: 'v0.1.0-test',
|
||||
allow_guild_creation: true,
|
||||
max_guilds_per_user: 5,
|
||||
max_members_per_guild: 100,
|
||||
max_message_length: 2000,
|
||||
user_count: 1,
|
||||
guild_count: 0,
|
||||
};
|
||||
|
||||
describe('страница регистрации', () => {
|
||||
it('успешная регистрация ведёт на /onboarding', async () => {
|
||||
const user = makeUser({ onboarding_completed: false });
|
||||
const fetchMock = installFetch([
|
||||
{
|
||||
match: '/api/v1/instance',
|
||||
response: () => json({ instance: { ...instanceBase, registration_enabled: true } }),
|
||||
},
|
||||
{ match: '/api/v1/auth/register', method: 'POST', response: () => json({ user }) },
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
]);
|
||||
|
||||
const { router } = renderApp('/register');
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
await visitor.type(await screen.findByLabelText('Имя пользователя'), 'alice');
|
||||
await visitor.type(screen.getByLabelText('Отображаемое имя'), 'Алиса');
|
||||
await visitor.type(screen.getByLabelText('Почта'), 'alice@example.com');
|
||||
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Создать аккаунт' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/onboarding');
|
||||
});
|
||||
expect(await screen.findByText('Привет, Alice!')).toBeVisible();
|
||||
|
||||
const registerCall = fetchMock.mock.calls.find(([input]) =>
|
||||
String(input).includes('/auth/register'),
|
||||
);
|
||||
const init = registerCall?.[1] as RequestInit;
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
username: 'alice',
|
||||
display_name: 'Алиса',
|
||||
email: 'alice@example.com',
|
||||
password: 'super-secret-1',
|
||||
locale: 'ru',
|
||||
});
|
||||
});
|
||||
|
||||
it('не отправляет форму с некорректными данными', async () => {
|
||||
const fetchMock = installFetch([
|
||||
{
|
||||
match: '/api/v1/instance',
|
||||
response: () => json({ instance: { ...instanceBase, registration_enabled: true } }),
|
||||
},
|
||||
]);
|
||||
|
||||
renderApp('/register');
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
await visitor.type(await screen.findByLabelText('Имя пользователя'), 'al');
|
||||
await visitor.type(screen.getByLabelText('Отображаемое имя'), 'Алиса');
|
||||
await visitor.type(screen.getByLabelText('Почта'), 'not-an-email');
|
||||
await visitor.type(screen.getByLabelText('Пароль'), 'short');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Создать аккаунт' }));
|
||||
|
||||
expect(await screen.findByText(/Имя пользователя: 3–32 символа/u)).toBeVisible();
|
||||
expect(screen.getByText('Укажите корректный адрес почты.')).toBeVisible();
|
||||
expect(screen.getByText('Пароль должен быть не короче 8 символов.')).toBeVisible();
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input]) => String(input).includes('/auth/register')),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('при registration_enabled=false показывает сообщение вместо формы', async () => {
|
||||
installFetch([
|
||||
{
|
||||
match: '/api/v1/instance',
|
||||
response: () => json({ instance: { ...instanceBase, registration_enabled: false } }),
|
||||
},
|
||||
]);
|
||||
|
||||
renderApp('/register');
|
||||
|
||||
expect(await screen.findByText('Регистрация закрыта')).toBeVisible();
|
||||
expect(screen.queryByLabelText('Имя пользователя')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Создать аккаунт' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import '@testing-library/jest-dom/vitest';
|
||||
import { afterEach, vi } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
// Общий экземпляр i18next для всех тестов: без него useTranslation вернёт ключи.
|
||||
import '@/i18n';
|
||||
import { setQueryClient } from '@/lib/queryClient';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
// Globals stubbed with vi.stubGlobal must be dropped between tests,
|
||||
@@ -9,4 +13,5 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
setQueryClient(null);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user