feat(web): друзья, личные беседы и вход только по приглашению
Раздел «Друзья» по запросу пользователя: - первая иконка рейки — «Друзья», серверы ниже за разделителем; личные беседы не засоряют список серверов; - сайдбар друзей: беседы с точками статуса и бейджами непрочитанного, список друзей с кликом на беседу; - контент: пустое состояние «Добавь друзей для общения» с поиском, поиск по логину (debounce), добавление в друзья, входящие и исходящие заявки, меню «…» (удалить/заблокировать), поповер последнего входа в часовом поясе пользователя (по умолчанию МСК), блок «Присоединиться по приглашению» с предпросмотром сервера; - личный чат на существующих компонентах: права по can_send канала, заголовок с именем и статусом собеседника, поиск, пины, ack и typing работают; - realtime: READY dm_channels, события DM_CHANNEL_CREATE, RELATIONSHIP_UPDATE, PRESENCE_UPDATE и MESSAGE_CREATE в беседах; - новый пользователь: «У вас пока нет серверов», создание сервера и вход по приглашению (автовступления нет); - 239 тестов Vitest (было 219), тест паритета ключей i18n (527 ключей).
This commit is contained in:
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { removeRelationship, type RelationshipUser } from '@/api/friends';
|
import { removeRelationship, type RelationshipUser } from '@/api/friends';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
||||||
import { Popover } from '@/components/ui/Popover';
|
import { Popover } from '@/components/ui/Popover';
|
||||||
import { Button } from '@/components/ui/primitives';
|
import { Button } from '@/components/ui/primitives';
|
||||||
@@ -131,6 +132,12 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
|
|||||||
<p className="px-1 py-0.5 text-xs text-fg">{hint}</p>
|
<p className="px-1 py-0.5 text-xs text-fg">{hint}</p>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
|
{openDm.isError || remove.isError ? (
|
||||||
|
<span className="absolute right-2 top-full z-10 w-64">
|
||||||
|
<ErrorNotice error={openDm.error ?? remove.error} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Menu
|
<Menu
|
||||||
open={menuOpen}
|
open={menuOpen}
|
||||||
label={t('friends.list.menu', { name: displayName })}
|
label={t('friends.list.menu', { name: displayName })}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function FriendsView() {
|
|||||||
aria-expanded={searchOpen}
|
aria-expanded={searchOpen}
|
||||||
onClick={() => setSearchOpen((value) => !value)}
|
onClick={() => setSearchOpen((value) => !value)}
|
||||||
>
|
>
|
||||||
{t('friends.empty.search')}
|
{t('friends.search.open')}
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ import { unreadCount, useMessagesStore, type ChannelMessagesState } from '@/stor
|
|||||||
/**
|
/**
|
||||||
* Непрочитанные личной беседы.
|
* Непрочитанные личной беседы.
|
||||||
*
|
*
|
||||||
* Для беседы с загруженной историей счётчик считается как у комнат
|
* Два сигнала, как у комнат:
|
||||||
* (`unreadCount`). Пока история не загружена, сравниваем `last_message_id`
|
* - загруженная история — точное число (`unreadCount`);
|
||||||
* канала с отметкой чтения из READY/`READ_STATE_UPDATE`: точного числа в этом
|
* - `last_message_id` канала против отметки чтения из READY /
|
||||||
* случае нет, поэтому `exact = false` — в сайдбаре рисуем точку, а не цифру.
|
* `READ_STATE_UPDATE` — когда история ещё не загружена, точного числа нет,
|
||||||
|
* поэтому в сайдбаре рисуем точку (`exact = false`), а не цифру.
|
||||||
|
*
|
||||||
|
* Сигналы объединяются через максимум: сообщение в уже открытой беседе могло
|
||||||
|
* прийти событием, когда история загружена, а отметки чтения ещё нет.
|
||||||
*/
|
*/
|
||||||
export function useDmUnread(channel: Channel): {
|
export function useDmUnread(channel: Channel): {
|
||||||
unread: number;
|
unread: number;
|
||||||
@@ -22,14 +26,17 @@ export function useDmUnread(channel: Channel): {
|
|||||||
[channel.id],
|
[channel.id],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// Отметки чтения известны после снапшота READY: отсутствие записи для
|
||||||
|
// беседы означает «не читалась».
|
||||||
|
const snapshotApplied = useMessagesStore((state) => state.snapshotApplied);
|
||||||
const mentions = record?.mentionCount ?? 0;
|
const mentions = record?.mentionCount ?? 0;
|
||||||
|
|
||||||
if (record !== undefined && record.messages.length > 0) {
|
const hasHistory = record !== undefined && record.messages.length > 0;
|
||||||
return { unread: unreadCount(record), mentions, exact: true };
|
const fromHistory = hasHistory ? unreadCount(record) : 0;
|
||||||
}
|
const known = record?.readStateKnown === true || snapshotApplied;
|
||||||
|
|
||||||
const known = record?.readStateKnown === true;
|
|
||||||
const marker = record?.lastReadMessageId ?? null;
|
const marker = record?.lastReadMessageId ?? null;
|
||||||
const hasNewer = channel.last_message_id !== undefined && channel.last_message_id !== marker;
|
const hasNewer = channel.last_message_id !== undefined && channel.last_message_id !== marker;
|
||||||
return { unread: known && hasNewer ? 1 : 0, mentions, exact: false };
|
const unread = Math.max(fromHistory, known && hasNewer ? 1 : 0);
|
||||||
|
|
||||||
|
return { unread, mentions, exact: fromHistory > 0 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,8 @@
|
|||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "You have no servers yet",
|
"title": "You have no servers yet",
|
||||||
"body": "Create your own server or join one with an invite."
|
"body": "Create your own server or join one with an invite.",
|
||||||
|
"create": "Create your own server"
|
||||||
},
|
},
|
||||||
"leave": "Leave server",
|
"leave": "Leave server",
|
||||||
"leaveConfirm": "Leave the server “{{name}}”?",
|
"leaveConfirm": "Leave the server “{{name}}”?",
|
||||||
@@ -249,7 +250,8 @@
|
|||||||
"adding": "Sending…",
|
"adding": "Sending…",
|
||||||
"requested": "Request sent",
|
"requested": "Request sent",
|
||||||
"friend": "Already friends",
|
"friend": "Already friends",
|
||||||
"message": "Message"
|
"message": "Message",
|
||||||
|
"open": "Search friends"
|
||||||
},
|
},
|
||||||
"requests": {
|
"requests": {
|
||||||
"incoming": "Incoming requests",
|
"incoming": "Incoming requests",
|
||||||
|
|||||||
@@ -176,7 +176,8 @@
|
|||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "У вас пока нет серверов",
|
"title": "У вас пока нет серверов",
|
||||||
"body": "Создайте свой сервер или присоединитесь по приглашению."
|
"body": "Создайте свой сервер или присоединитесь по приглашению.",
|
||||||
|
"create": "Создать свой сервер"
|
||||||
},
|
},
|
||||||
"leave": "Покинуть сервер",
|
"leave": "Покинуть сервер",
|
||||||
"leaveConfirm": "Покинуть сервер «{{name}}»?",
|
"leaveConfirm": "Покинуть сервер «{{name}}»?",
|
||||||
@@ -249,7 +250,8 @@
|
|||||||
"adding": "Отправляем…",
|
"adding": "Отправляем…",
|
||||||
"requested": "Заявка отправлена",
|
"requested": "Заявка отправлена",
|
||||||
"friend": "Уже в друзьях",
|
"friend": "Уже в друзьях",
|
||||||
"message": "Написать"
|
"message": "Написать",
|
||||||
|
"open": "Поиск друзей"
|
||||||
},
|
},
|
||||||
"requests": {
|
"requests": {
|
||||||
"incoming": "Входящие заявки",
|
"incoming": "Входящие заявки",
|
||||||
|
|||||||
@@ -137,7 +137,9 @@ export function FriendsSidebar({ channelId }: { channelId: string | null }) {
|
|||||||
{t('friends.dmSection')}
|
{t('friends.dmSection')}
|
||||||
</h2>
|
</h2>
|
||||||
{direct.length === 0 ? (
|
{direct.length === 0 ? (
|
||||||
<p className="px-2 text-xs text-fg-muted">{t('friends.dmEmpty')}</p>
|
<p className="px-2 text-xs text-fg-muted" data-testid="dm-sidebar-empty">
|
||||||
|
{t('friends.dmEmpty')}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="gl-stagger flex flex-col gap-0.5" data-testid="dm-list">
|
<ul className="gl-stagger flex flex-col gap-0.5" data-testid="dm-list">
|
||||||
{direct.map((channel) => (
|
{direct.map((channel) => (
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil
|
|||||||
</svg>
|
</svg>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<div role="separator" aria-hidden="true" className="my-0.5 h-px w-8 bg-border/60" />
|
<div role="separator" className="my-0.5 h-px w-8 bg-border/60" />
|
||||||
|
|
||||||
<ul className="gl-stagger flex flex-col items-center gap-2" data-testid="guild-rail">
|
<ul className="gl-stagger flex flex-col items-center gap-2" data-testid="guild-rail">
|
||||||
{guilds.map((guild) => {
|
{guilds.map((guild) => {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function NoGuildView() {
|
|||||||
<p className="mt-2 text-sm text-fg-muted">{t('guilds.empty.body')}</p>
|
<p className="mt-2 text-sm text-fg-muted">{t('guilds.empty.body')}</p>
|
||||||
|
|
||||||
<Button className="mt-4" onClick={openGuildCreate}>
|
<Button className="mt-4" onClick={openGuildCreate}>
|
||||||
{t('guilds.add')}
|
{t('guilds.empty.create')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<InviteJoin className="mt-6 border-t border-border/60 pt-4" />
|
<InviteJoin className="mt-6 border-t border-border/60 pt-4" />
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
const message = parseMessagePayload(event.d);
|
const message = parseMessagePayload(event.d);
|
||||||
if (message !== null) {
|
if (message !== null) {
|
||||||
messages.applyMessageCreate(message);
|
messages.applyMessageCreate(message);
|
||||||
|
// В личной беседе «последнее сообщение» — источник бейджа и порядка.
|
||||||
|
session.touchDmChannel(message.channel_id, message.id, message.created_at);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ interface SessionState {
|
|||||||
upsertDmChannel: (channel: Channel) => void;
|
upsertDmChannel: (channel: Channel) => void;
|
||||||
/** PRESENCE_UPDATE: статус собеседника в списке бесед и шапке чата. */
|
/** PRESENCE_UPDATE: статус собеседника в списке бесед и шапке чата. */
|
||||||
patchDmPresence: (userId: string, status: UserStatus | undefined) => void;
|
patchDmPresence: (userId: string, status: UserStatus | undefined) => void;
|
||||||
|
/** MESSAGE_CREATE в беседе: сдвигаем «последнее сообщение» (бейджи и порядок). */
|
||||||
|
touchDmChannel: (channelId: string, messageId: string, at: string) => void;
|
||||||
/** DM_CHANNEL_CREATE: перечитываем беседы по REST. */
|
/** DM_CHANNEL_CREATE: перечитываем беседы по REST. */
|
||||||
reloadDmChannels: () => Promise<void>;
|
reloadDmChannels: () => Promise<void>;
|
||||||
/** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */
|
/** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */
|
||||||
@@ -269,6 +271,19 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
touchDmChannel: (channelId, messageId, at) => {
|
||||||
|
if (!get().dmChannels.some((channel) => channel.id === channelId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
dmChannels: get().dmChannels.map((channel) =>
|
||||||
|
channel.id === channelId
|
||||||
|
? { ...channel, last_message_id: messageId, last_message_at: at }
|
||||||
|
: channel,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
reloadDmChannels: async () => {
|
reloadDmChannels: async () => {
|
||||||
try {
|
try {
|
||||||
set({ dmChannels: await fetchDirectChannels() });
|
set({ dmChannels: await fetchDirectChannels() });
|
||||||
|
|||||||
@@ -0,0 +1,802 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { act, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import type { RelationshipUser, Relationships } from '@/api/friends';
|
||||||
|
import type { User } from '@/api/types';
|
||||||
|
import {
|
||||||
|
findRequest,
|
||||||
|
installFetch,
|
||||||
|
installGatewaySocket,
|
||||||
|
json,
|
||||||
|
makeMessage,
|
||||||
|
makeReadySnapshot,
|
||||||
|
makeUser,
|
||||||
|
messagesRoutes,
|
||||||
|
recordedRequests,
|
||||||
|
renderApp,
|
||||||
|
type FetchRoute,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раздел «Друзья» и личные беседы: рейка, пустое состояние, поиск, заявки,
|
||||||
|
* статусы, поповер последнего входа, DM-чат, приглашения и события шлюза.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const user = makeUser();
|
||||||
|
|
||||||
|
/** Инстанс с «главным» сервером: автовступления в него больше быть не должно. */
|
||||||
|
const instance = {
|
||||||
|
instance: {
|
||||||
|
name: 'glchat-test',
|
||||||
|
registration_enabled: true,
|
||||||
|
allow_guild_creation: true,
|
||||||
|
max_guilds_per_user: 5,
|
||||||
|
main_guild_id: 'g-main',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const empty: Relationships = { friends: [], incoming: [], outgoing: [], blocked: [] };
|
||||||
|
|
||||||
|
/** Пользователь со связью: значения по умолчанию — «друг в сети». */
|
||||||
|
function relationship(
|
||||||
|
overrides: Partial<RelationshipUser> & { user_id: string },
|
||||||
|
): RelationshipUser {
|
||||||
|
const username = overrides.username ?? overrides.user_id;
|
||||||
|
return {
|
||||||
|
username,
|
||||||
|
display_name: username,
|
||||||
|
status: 'online',
|
||||||
|
visible_status: 'online',
|
||||||
|
is_instance_admin: false,
|
||||||
|
badges: [],
|
||||||
|
timezone: 'Europe/Moscow',
|
||||||
|
relationship: 'friend',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bob = relationship({ user_id: 'user-2', username: 'bob', display_name: 'Боб' });
|
||||||
|
|
||||||
|
/** Базовые ответы оболочки: профиль, инстанс, серверы, связи. */
|
||||||
|
function baseRoutes(
|
||||||
|
relationships: Relationships | (() => Relationships) = empty,
|
||||||
|
extra: FetchRoute[] = [],
|
||||||
|
): FetchRoute[] {
|
||||||
|
return [
|
||||||
|
...extra,
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me/relationships',
|
||||||
|
response: () => json(typeof relationships === 'function' ? relationships() : relationships),
|
||||||
|
},
|
||||||
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||||
|
{ match: '/api/v1/users/@me/channels', response: () => json({ channels: [] }) },
|
||||||
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||||
|
{ match: '/api/v1/instance', response: () => json(instance) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Личная беседа в REST-форме (`GET /users/@me/channels`). */
|
||||||
|
function directChannel(id: string, displayName = 'Боб', status = 'online') {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'dm',
|
||||||
|
can_send: true,
|
||||||
|
can_view: true,
|
||||||
|
recipient: {
|
||||||
|
user_id: 'user-2',
|
||||||
|
username: 'bob',
|
||||||
|
display_name: displayName,
|
||||||
|
status,
|
||||||
|
visible_status: status,
|
||||||
|
timezone: 'Europe/Moscow',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Событие шлюза внутри `act`: обработчик обновляет сторы, а часть работы
|
||||||
|
* (перечитывание списков) завершается промисами.
|
||||||
|
*/
|
||||||
|
async function dispatchEvent(
|
||||||
|
gateway: ReturnType<typeof installGatewaySocket>,
|
||||||
|
type: string,
|
||||||
|
payload: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
gateway.dispatch(type, payload);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('раздел друзей', () => {
|
||||||
|
it('первой иконкой рейки открывает раздел друзей', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
const snapshot = makeReadySnapshot([
|
||||||
|
{ id: 'g-1', name: 'Сервер', channels: [{ id: 'c-1', name: 'общий' }] },
|
||||||
|
]);
|
||||||
|
installFetch(baseRoutes(empty, messagesRoutes('c-1', [])));
|
||||||
|
const gateway = installGatewaySocket(snapshot);
|
||||||
|
|
||||||
|
const { router } = renderApp('/app/g-1/c-1');
|
||||||
|
await screen.findByTestId('guild-rail');
|
||||||
|
await gateway.greet();
|
||||||
|
await screen.findByLabelText('Открыть комнату общий');
|
||||||
|
|
||||||
|
// Друзья — выше списка серверов.
|
||||||
|
const friendsIcon = screen.getByTestId('friends-rail-link');
|
||||||
|
expect(friendsIcon.compareDocumentPosition(screen.getByTestId('guild-rail'))).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
);
|
||||||
|
expect(friendsIcon).toHaveAccessibleName('Друзья');
|
||||||
|
|
||||||
|
await visitor.click(friendsIcon);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(router.state.location.pathname).toBe('/app/friends');
|
||||||
|
});
|
||||||
|
// Сайдбар раздела: заголовок «Друзья».
|
||||||
|
expect(await screen.findByRole('heading', { level: 1, name: 'Друзья' })).toBeVisible();
|
||||||
|
expect(screen.getByRole('link', { name: 'Друзья' })).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без друзей и бесед показывает пустое состояние с кнопкой поиска', async () => {
|
||||||
|
installFetch(baseRoutes(empty));
|
||||||
|
|
||||||
|
renderApp('/app/friends');
|
||||||
|
|
||||||
|
expect(await screen.findByText('Добавь друзей для общения')).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: 'Найти друзей' })).toBeVisible();
|
||||||
|
expect(screen.queryByTestId('dm-list')).toBeNull();
|
||||||
|
expect(screen.getByTestId('dm-sidebar-empty')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ищет пользователей по логину и отправляет заявку в друзья', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
let requested = false;
|
||||||
|
const fetchMock = installFetch(
|
||||||
|
baseRoutes(
|
||||||
|
() => (requested ? { ...empty, outgoing: [bob] } : empty),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/search',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
users: [
|
||||||
|
relationship({
|
||||||
|
user_id: 'user-2',
|
||||||
|
username: 'bob',
|
||||||
|
display_name: 'Боб',
|
||||||
|
relationship: 'none',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me/relationships',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => {
|
||||||
|
requested = true;
|
||||||
|
return json({ ok: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp('/app/friends');
|
||||||
|
await visitor.click(await screen.findByRole('button', { name: 'Найти друзей' }));
|
||||||
|
|
||||||
|
const input = await screen.findByLabelText('Поиск по логину');
|
||||||
|
await visitor.type(input, 'bob');
|
||||||
|
|
||||||
|
// Debounce 400 мс: запрос уходит после паузы в наборе.
|
||||||
|
expect(await screen.findByText('Боб', {}, { timeout: 3000 })).toBeVisible();
|
||||||
|
expect(screen.getByText('@bob')).toBeVisible();
|
||||||
|
expect(findRequest(fetchMock, { url: '/users/search' })?.url).toContain('q=bob');
|
||||||
|
|
||||||
|
await visitor.click(screen.getByRole('button', { name: 'Добавить в друзья' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Заявка отправлена')).toBeVisible();
|
||||||
|
expect(
|
||||||
|
findRequest(fetchMock, { url: '/users/@me/relationships', method: 'POST' })?.body,
|
||||||
|
).toEqual({ username: 'bob' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('принимает входящую заявку и отменяет исходящую', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
let accepted = false;
|
||||||
|
const fetchMock = installFetch(
|
||||||
|
baseRoutes(
|
||||||
|
() => ({
|
||||||
|
friends: accepted
|
||||||
|
? [relationship({ user_id: 'user-3', username: 'carol', display_name: 'Кэрол' })]
|
||||||
|
: [],
|
||||||
|
incoming: accepted
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
relationship({
|
||||||
|
user_id: 'user-3',
|
||||||
|
username: 'carol',
|
||||||
|
display_name: 'Кэрол',
|
||||||
|
relationship: 'incoming',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
outgoing: [
|
||||||
|
relationship({
|
||||||
|
user_id: 'user-4',
|
||||||
|
username: 'dave',
|
||||||
|
display_name: 'Дэйв',
|
||||||
|
relationship: 'outgoing',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
blocked: [],
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me/relationships/user-3/accept',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => {
|
||||||
|
accepted = true;
|
||||||
|
return json({ ok: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me/relationships/user-4',
|
||||||
|
method: 'DELETE',
|
||||||
|
response: () => json({ ok: true }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp('/app/friends');
|
||||||
|
|
||||||
|
const incoming = await screen.findByTestId('friends-incoming');
|
||||||
|
expect(incoming).toHaveTextContent('@carol');
|
||||||
|
await visitor.click(within(incoming).getByRole('button', { name: 'Принять' }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
findRequest(fetchMock, { url: '/users/@me/relationships/user-3/accept', method: 'POST' }),
|
||||||
|
).toBeDefined();
|
||||||
|
expect(await screen.findByTestId('friends-list')).toHaveTextContent('Кэрол');
|
||||||
|
|
||||||
|
const outgoing = screen.getByTestId('friends-outgoing');
|
||||||
|
await visitor.click(within(outgoing).getByRole('button', { name: 'Отменить' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
findRequest(fetchMock, { url: '/users/@me/relationships/user-4', method: 'DELETE' }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отклоняет входящую заявку тем же DELETE', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
const fetchMock = installFetch(
|
||||||
|
baseRoutes(
|
||||||
|
{
|
||||||
|
...empty,
|
||||||
|
incoming: [
|
||||||
|
relationship({
|
||||||
|
user_id: 'user-3',
|
||||||
|
username: 'carol',
|
||||||
|
display_name: 'Кэрол',
|
||||||
|
relationship: 'incoming',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me/relationships/user-3',
|
||||||
|
method: 'DELETE',
|
||||||
|
response: () => json({ ok: true }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp('/app/friends');
|
||||||
|
const incoming = await screen.findByTestId('friends-incoming');
|
||||||
|
await visitor.click(within(incoming).getByRole('button', { name: 'Отклонить' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
findRequest(fetchMock, { url: '/users/@me/relationships/user-3', method: 'DELETE' }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('рисует цветные точки статусов в списке друзей', async () => {
|
||||||
|
installFetch(
|
||||||
|
baseRoutes({
|
||||||
|
friends: [
|
||||||
|
relationship({ user_id: 'user-2', username: 'bob', display_name: 'Боб' }),
|
||||||
|
relationship({
|
||||||
|
user_id: 'user-3',
|
||||||
|
username: 'carol',
|
||||||
|
display_name: 'Кэрол',
|
||||||
|
status: 'idle',
|
||||||
|
visible_status: 'idle',
|
||||||
|
}),
|
||||||
|
relationship({
|
||||||
|
user_id: 'user-4',
|
||||||
|
username: 'dave',
|
||||||
|
display_name: 'Дэйв',
|
||||||
|
status: 'dnd',
|
||||||
|
visible_status: 'dnd',
|
||||||
|
}),
|
||||||
|
relationship({
|
||||||
|
user_id: 'user-5',
|
||||||
|
username: 'erin',
|
||||||
|
display_name: 'Эрин',
|
||||||
|
status: 'offline',
|
||||||
|
visible_status: 'offline',
|
||||||
|
}),
|
||||||
|
relationship({
|
||||||
|
user_id: 'user-6',
|
||||||
|
username: 'frank',
|
||||||
|
display_name: 'Фрэнк',
|
||||||
|
status: 'invisible',
|
||||||
|
visible_status: 'invisible',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
incoming: [],
|
||||||
|
outgoing: [],
|
||||||
|
blocked: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp('/app/friends');
|
||||||
|
const list = await screen.findByTestId('friends-list');
|
||||||
|
|
||||||
|
const dots: [string, string, string][] = [
|
||||||
|
['user-2', 'online', 'bg-success'],
|
||||||
|
['user-3', 'idle', 'bg-warning'],
|
||||||
|
['user-4', 'dnd', 'bg-fg-muted'],
|
||||||
|
['user-5', 'offline', 'bg-danger'],
|
||||||
|
['user-6', 'invisible', 'bg-info'],
|
||||||
|
];
|
||||||
|
for (const [id, status, className] of dots) {
|
||||||
|
const dot = within(list).getByTestId(`friend-status-${id}`);
|
||||||
|
expect(dot).toHaveAttribute('data-status', status);
|
||||||
|
expect(dot).toHaveClass(className);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('показывает последний вход в часовом поясе пользователя (МСК по умолчанию)', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
installFetch(
|
||||||
|
baseRoutes({
|
||||||
|
friends: [
|
||||||
|
relationship({
|
||||||
|
user_id: 'user-2',
|
||||||
|
username: 'bob',
|
||||||
|
display_name: 'Боб',
|
||||||
|
status: 'offline',
|
||||||
|
visible_status: 'offline',
|
||||||
|
last_seen_at: '2026-09-19T10:00:00Z',
|
||||||
|
}),
|
||||||
|
relationship({
|
||||||
|
user_id: 'user-5',
|
||||||
|
username: 'erin',
|
||||||
|
display_name: 'Эрин',
|
||||||
|
status: 'offline',
|
||||||
|
visible_status: 'offline',
|
||||||
|
}),
|
||||||
|
relationship({ user_id: 'user-6', username: 'frank', display_name: 'Фрэнк' }),
|
||||||
|
],
|
||||||
|
incoming: [],
|
||||||
|
outgoing: [],
|
||||||
|
blocked: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp('/app/friends');
|
||||||
|
await screen.findByTestId('friends-list');
|
||||||
|
|
||||||
|
// Наведение на имя: 10:00 UTC — это 13:00 в Москве.
|
||||||
|
await visitor.hover(screen.getByTestId('friend-name-user-2'));
|
||||||
|
expect(await screen.findByText('Был(а) в сети: 19.09.2026, 13:00')).toBeVisible();
|
||||||
|
await visitor.unhover(screen.getByTestId('friend-name-user-2'));
|
||||||
|
|
||||||
|
// Подсказка доступна и с клавиатуры: фокус на имени открывает поповер.
|
||||||
|
const erin = screen.getByTestId('friend-name-user-5');
|
||||||
|
act(() => {
|
||||||
|
erin.focus();
|
||||||
|
});
|
||||||
|
expect(await screen.findByText('Не в сети давно')).toBeVisible();
|
||||||
|
act(() => {
|
||||||
|
erin.blur();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Пользователь в сети — время последнего входа не показываем.
|
||||||
|
await visitor.hover(screen.getByTestId('friend-name-user-6'));
|
||||||
|
expect(await screen.findByText('Сейчас в сети')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('переводит время последнего входа в пояс из настроек профиля', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
const ekbUser: User = makeUser({ timezone: 'Asia/Yekaterinburg' });
|
||||||
|
installFetch(
|
||||||
|
baseRoutes(
|
||||||
|
{
|
||||||
|
friends: [
|
||||||
|
relationship({
|
||||||
|
user_id: 'user-2',
|
||||||
|
username: 'bob',
|
||||||
|
display_name: 'Боб',
|
||||||
|
status: 'offline',
|
||||||
|
visible_status: 'offline',
|
||||||
|
last_seen_at: '2026-09-19T10:00:00Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
incoming: [],
|
||||||
|
outgoing: [],
|
||||||
|
blocked: [],
|
||||||
|
},
|
||||||
|
// Профиль с поясом из настроек: маршруты из `extra` проверяются раньше.
|
||||||
|
[{ match: '/api/v1/users/@me', response: () => json({ user: ekbUser }) }],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp('/app/friends');
|
||||||
|
await screen.findByTestId('friends-list');
|
||||||
|
|
||||||
|
await visitor.hover(screen.getByTestId('friend-name-user-2'));
|
||||||
|
// 10:00 UTC — 15:00 в Екатеринбурге (UTC+5).
|
||||||
|
expect(await screen.findByText('Был(а) в сети: 19.09.2026, 15:00')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('открывает личную беседу и отправляет сообщение', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
const fetchMock = installFetch(
|
||||||
|
baseRoutes({ ...empty, friends: [bob] }, [
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me/channels',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => json({ channel: directChannel('dm-1') }),
|
||||||
|
},
|
||||||
|
...messagesRoutes(
|
||||||
|
'dm-1',
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
match: '/api/v1/channels/dm-1/messages',
|
||||||
|
method: 'POST',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
message: makeMessage({
|
||||||
|
id: 'm-1',
|
||||||
|
channel_id: 'dm-1',
|
||||||
|
author_id: 'user-1',
|
||||||
|
content: 'привет',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { router } = renderApp('/app/friends');
|
||||||
|
const list = await screen.findByTestId('friends-list');
|
||||||
|
await visitor.click(within(list).getByRole('button', { name: 'Написать' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(router.state.location.pathname).toBe('/app/friends/dm-1');
|
||||||
|
});
|
||||||
|
expect(findRequest(fetchMock, { url: '/users/@me/channels', method: 'POST' })?.body).toEqual({
|
||||||
|
recipient_id: 'user-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Шапка беседы: имя собеседника и точка статуса.
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Боб' })).toBeVisible();
|
||||||
|
expect(screen.getByTestId('dm-status')).toHaveAttribute('data-status', 'online');
|
||||||
|
|
||||||
|
const composer = await screen.findByTestId('composer');
|
||||||
|
await visitor.type(within(composer).getByRole('textbox'), 'привет');
|
||||||
|
await visitor.click(within(composer).getByRole('button', { name: 'Отправить' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
findRequest(fetchMock, { url: '/api/v1/channels/dm-1/messages', method: 'POST' })?.body,
|
||||||
|
).toMatchObject({ content: 'привет' });
|
||||||
|
});
|
||||||
|
expect(await screen.findByTestId('message-list')).toHaveTextContent('привет');
|
||||||
|
// Композер в личной беседе работает без прав сервера.
|
||||||
|
expect(within(composer).getByRole('textbox')).toHaveAttribute('placeholder', 'Написать Боб');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('открывает беседу по прямой ссылке, если её нет в снапшоте', async () => {
|
||||||
|
installFetch(
|
||||||
|
baseRoutes({ ...empty, friends: [bob] }, [
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me/channels',
|
||||||
|
response: () => json({ channels: [directChannel('dm-7', 'Кэрол')] }),
|
||||||
|
},
|
||||||
|
...messagesRoutes('dm-7', []),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp('/app/friends/dm-7');
|
||||||
|
|
||||||
|
// Список бесед подтягивается по REST — беседа открывается.
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Кэрол' })).toBeVisible();
|
||||||
|
expect(await screen.findByTestId('composer')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('присоединяется по приглашению: предпросмотр и сервер в рейке', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
const guildDetail = {
|
||||||
|
id: 'g-inv',
|
||||||
|
name: 'Приглашённые',
|
||||||
|
description: '',
|
||||||
|
owner_id: 'user-9',
|
||||||
|
is_main: false,
|
||||||
|
member_count: 3,
|
||||||
|
roles: [],
|
||||||
|
my_role_ids: [],
|
||||||
|
my_permissions: ['SEND_MESSAGES'],
|
||||||
|
};
|
||||||
|
const fetchMock = installFetch(
|
||||||
|
baseRoutes(empty, [
|
||||||
|
{
|
||||||
|
match: '/api/v1/invites/ab12cd',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
invite: { code: 'ab12cd', guild_id: 'g-inv' },
|
||||||
|
guild: { id: 'g-inv', name: 'Приглашённые', member_count: 3, is_member: false },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/invites/ab12cd',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => json({ guild_id: 'g-inv' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds/g-inv/channels',
|
||||||
|
response: () =>
|
||||||
|
json({ channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }] }),
|
||||||
|
},
|
||||||
|
{ match: '/api/v1/guilds/g-inv/members', response: () => json({ members: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-inv/roles', response: () => json({ roles: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-inv', response: () => json({ guild: guildDetail }) },
|
||||||
|
...messagesRoutes('c-1', []),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { router } = renderApp('/app/friends');
|
||||||
|
|
||||||
|
const input = await screen.findByLabelText('Код или ссылка приглашения');
|
||||||
|
await visitor.type(input, 'https://gl.localhost/invite/ab12cd');
|
||||||
|
|
||||||
|
const preview = await screen.findByTestId('invite-preview', {}, { timeout: 3000 });
|
||||||
|
expect(preview).toHaveTextContent('Приглашённые');
|
||||||
|
expect(preview).toHaveTextContent('Участников: 3');
|
||||||
|
|
||||||
|
await visitor.click(screen.getByRole('button', { name: 'Присоединиться' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(findRequest(fetchMock, { url: '/invites/ab12cd', method: 'POST' })).toBeDefined();
|
||||||
|
});
|
||||||
|
// Сервер появился в рейке, открылся его первый канал.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(router.state.location.pathname).toBe('/app/g-inv/c-1');
|
||||||
|
});
|
||||||
|
const rail = screen.getByTestId('guild-rail');
|
||||||
|
expect(await within(rail).findByLabelText('Открыть сервер Приглашённые')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('сообщает о недействительном приглашении', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
installFetch(
|
||||||
|
baseRoutes(empty, [
|
||||||
|
{
|
||||||
|
match: '/api/v1/invites/dead',
|
||||||
|
response: () =>
|
||||||
|
json({ error: { code: 'invite.expired', message: 'invite has expired' } }, 410),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp('/app/friends');
|
||||||
|
await visitor.type(await screen.findByLabelText('Код или ссылка приглашения'), 'dead');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Срок действия приглашения истёк.', {}, { timeout: 3000 }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не вступает ни в какой сервер автоматически', async () => {
|
||||||
|
const fetchMock = installFetch(baseRoutes(empty));
|
||||||
|
const gateway = installGatewaySocket(makeReadySnapshot([]));
|
||||||
|
|
||||||
|
renderApp('/app/friends');
|
||||||
|
await screen.findByTestId('guild-rail');
|
||||||
|
await gateway.greet();
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('friends-empty')).toBeVisible();
|
||||||
|
expect(screen.getByTestId('guild-rail').children).toHaveLength(0);
|
||||||
|
// «Главного сервера» в рейке больше нет: только приглашение.
|
||||||
|
expect(recordedRequests(fetchMock).some((request) => request.url.includes('/join'))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('invite-join')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('события шлюза о друзьях', () => {
|
||||||
|
it('DM_CHANNEL_CREATE перечитывает список бесед', async () => {
|
||||||
|
let created = false;
|
||||||
|
installFetch(
|
||||||
|
baseRoutes(empty, [
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me/channels',
|
||||||
|
response: () => json({ channels: created ? [directChannel('dm-1')] : [] }),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const gateway = installGatewaySocket(makeReadySnapshot([]));
|
||||||
|
|
||||||
|
renderApp('/app/friends');
|
||||||
|
await screen.findByTestId('guild-rail');
|
||||||
|
await gateway.greet();
|
||||||
|
expect(await screen.findByText('Личных бесед пока нет.')).toBeVisible();
|
||||||
|
|
||||||
|
created = true;
|
||||||
|
await dispatchEvent(gateway, 'DM_CHANNEL_CREATE', { channel_id: 'dm-1' });
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('dm-link-dm-1')).toBeVisible();
|
||||||
|
expect(screen.getByTestId('dm-status-dm-1')).toHaveAttribute('data-status', 'online');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RELATIONSHIP_UPDATE перечитывает друзей', async () => {
|
||||||
|
let hasFriend = false;
|
||||||
|
installFetch(baseRoutes(() => (hasFriend ? { ...empty, friends: [bob] } : empty)));
|
||||||
|
const gateway = installGatewaySocket(makeReadySnapshot([]));
|
||||||
|
|
||||||
|
renderApp('/app/friends');
|
||||||
|
await screen.findByTestId('guild-rail');
|
||||||
|
await gateway.greet();
|
||||||
|
expect(await screen.findByTestId('friends-empty')).toBeVisible();
|
||||||
|
|
||||||
|
hasFriend = true;
|
||||||
|
await dispatchEvent(gateway, 'RELATIONSHIP_UPDATE', { user_id: 'user-2' });
|
||||||
|
|
||||||
|
const list = await screen.findByTestId('friends-list');
|
||||||
|
expect(list).toHaveTextContent('Боб');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PRESENCE_UPDATE обновляет статус друга без перезагрузки', async () => {
|
||||||
|
const fetchMock = installFetch(
|
||||||
|
baseRoutes({ ...empty, friends: [bob] }, [
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me/channels',
|
||||||
|
response: () => json({ channels: [directChannel('dm-1')] }),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const gateway = installGatewaySocket(
|
||||||
|
makeReadySnapshot(
|
||||||
|
[],
|
||||||
|
[{ id: 'dm-1', name: 'Боб', recipient_id: 'user-2', status: 'online' }],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp('/app/friends');
|
||||||
|
const list = await screen.findByTestId('friends-list');
|
||||||
|
await gateway.greet();
|
||||||
|
expect(within(list).getByTestId('friend-status-user-2')).toHaveAttribute(
|
||||||
|
'data-status',
|
||||||
|
'online',
|
||||||
|
);
|
||||||
|
const before = recordedRequests(fetchMock).filter(
|
||||||
|
(request) => request.url.includes('/users/@me/relationships') && request.method === 'GET',
|
||||||
|
).length;
|
||||||
|
|
||||||
|
await dispatchEvent(gateway, 'PRESENCE_UPDATE', {
|
||||||
|
user_id: 'user-2',
|
||||||
|
status: 'dnd',
|
||||||
|
visible_status: 'dnd',
|
||||||
|
custom_status: 'занят',
|
||||||
|
last_seen_at: '2026-09-19T10:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dot = await within(list).findByTestId('friend-status-user-2');
|
||||||
|
expect(dot).toHaveAttribute('data-status', 'dnd');
|
||||||
|
expect(dot).toHaveClass('bg-fg-muted');
|
||||||
|
// Статус собеседника обновился и в сайдбаре личных бесед.
|
||||||
|
expect(await screen.findByTestId('dm-status-dm-1')).toHaveAttribute('data-status', 'dnd');
|
||||||
|
|
||||||
|
// Перезагрузки списка друзей не было: событие применено точечно.
|
||||||
|
const after = recordedRequests(fetchMock).filter(
|
||||||
|
(request) => request.url.includes('/users/@me/relationships') && request.method === 'GET',
|
||||||
|
).length;
|
||||||
|
expect(after).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MESSAGE_CREATE в беседе обновляет бейдж непрочитанного', async () => {
|
||||||
|
installFetch(
|
||||||
|
baseRoutes({ ...empty, friends: [bob] }, [
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me/channels',
|
||||||
|
response: () => json({ channels: [directChannel('dm-1')] }),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const gateway = installGatewaySocket(
|
||||||
|
makeReadySnapshot(
|
||||||
|
[],
|
||||||
|
[{ id: 'dm-1', name: 'Боб', recipient_id: 'user-2', last_message_id: 'm-1' }],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { router } = renderApp('/app/friends');
|
||||||
|
await screen.findByTestId('guild-rail');
|
||||||
|
await gateway.greet();
|
||||||
|
|
||||||
|
// Отметки чтения нет: беседа не читалась — в сайдбаре точка непрочитанного.
|
||||||
|
expect(await screen.findByTestId('dm-unread-dm-1')).toBeVisible();
|
||||||
|
|
||||||
|
await dispatchEvent(gateway, 'MESSAGE_CREATE', {
|
||||||
|
id: 'm-2',
|
||||||
|
channel_id: 'dm-1',
|
||||||
|
author_id: 'user-2',
|
||||||
|
content: 'эй',
|
||||||
|
created_at: '2026-09-19T10:05:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Новое сообщение сдвигает «последнее» в беседе — точка остаётся.
|
||||||
|
expect(screen.getByTestId('dm-unread-dm-1')).toBeVisible();
|
||||||
|
expect(router.state.location.pathname).toBe('/app/friends');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('открытая беседа отмечается прочитанной через ack', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
const fetchMock = installFetch(
|
||||||
|
baseRoutes({ ...empty, friends: [bob] }, [
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me/channels',
|
||||||
|
response: () => json({ channels: [directChannel('dm-1')] }),
|
||||||
|
},
|
||||||
|
...messagesRoutes('dm-1', [
|
||||||
|
makeMessage({
|
||||||
|
id: 'm-5',
|
||||||
|
channel_id: 'dm-1',
|
||||||
|
author_id: 'user-2',
|
||||||
|
content: 'привет',
|
||||||
|
created_at: '2026-09-19T10:05:00Z',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const gateway = installGatewaySocket(
|
||||||
|
makeReadySnapshot(
|
||||||
|
[],
|
||||||
|
[{ id: 'dm-1', name: 'Боб', recipient_id: 'user-2', last_message_id: 'm-5' }],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp('/app/friends');
|
||||||
|
await screen.findByTestId('guild-rail');
|
||||||
|
await gateway.greet();
|
||||||
|
|
||||||
|
// Беседа не читалась (последнее сообщение новее отметки) — есть бейдж.
|
||||||
|
expect(await screen.findByTestId('dm-unread-dm-1')).toBeVisible();
|
||||||
|
|
||||||
|
await visitor.click(screen.getByTestId('dm-link-dm-1'));
|
||||||
|
expect(await screen.findByTestId('message-list')).toHaveTextContent('привет');
|
||||||
|
|
||||||
|
// Беседа открыта и окно активно — уходит POST /ack с последним сообщением.
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(findRequest(fetchMock, { url: '/channels/dm-1/ack', method: 'POST' })).toBeDefined();
|
||||||
|
},
|
||||||
|
{ timeout: 3000 },
|
||||||
|
);
|
||||||
|
expect(findRequest(fetchMock, { url: '/channels/dm-1/ack', method: 'POST' })?.body).toEqual({
|
||||||
|
last_message_id: 'm-5',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+39
-9
@@ -7,6 +7,7 @@ import { GatewayOp } from '@/api/gateway';
|
|||||||
import type { Message } from '@/api/messages';
|
import type { Message } from '@/api/messages';
|
||||||
import { routes } from '@/router';
|
import { routes } from '@/router';
|
||||||
import { setQueryClient } from '@/lib/queryClient';
|
import { setQueryClient } from '@/lib/queryClient';
|
||||||
|
import { useFriendsStore } from '@/stores/friends';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
import { useMessagesStore } from '@/stores/messages';
|
import { useMessagesStore } from '@/stores/messages';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
@@ -120,19 +121,17 @@ export function resetStores(): void {
|
|||||||
useSessionStore.getState().reset();
|
useSessionStore.getState().reset();
|
||||||
useMessagesStore.getState().reset();
|
useMessagesStore.getState().reset();
|
||||||
useVoiceStore.getState().reset();
|
useVoiceStore.getState().reset();
|
||||||
|
useFriendsStore.getState().reset();
|
||||||
// Окно настроек — обычное состояние UI: между тестами оно должно быть закрыто.
|
// Окно настроек — обычное состояние UI: между тестами оно должно быть закрыто.
|
||||||
useUiStore.setState({
|
useUiStore.setState({
|
||||||
|
guildCreateOpen: false,
|
||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
settingsSection: 'profile',
|
settingsSection: 'profile',
|
||||||
settingsProfileTab: 'main',
|
settingsProfileTab: 'main',
|
||||||
});
|
});
|
||||||
useGatewayStore.setState({
|
// `reset` закрывает и общее соединение шлюза: иначе колбэки прошлого теста
|
||||||
status: 'idle',
|
// (переподключение, статус) меняли бы стор уже следующего.
|
||||||
heartbeatIntervalMs: null,
|
useGatewayStore.getState().reset();
|
||||||
sessionId: null,
|
|
||||||
attempts: 0,
|
|
||||||
invalidated: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Рендер приложения на заданном маршруте с изолированными сторами. */
|
/** Рендер приложения на заданном маршруте с изолированными сторами. */
|
||||||
@@ -206,8 +205,24 @@ interface SnapshotGuildInput {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Личная беседа в снапшоте READY. */
|
||||||
|
export interface SnapshotDmInput {
|
||||||
|
id: string;
|
||||||
|
/** Имя собеседника (сервер отдаёт display_name в поле `name`). */
|
||||||
|
name: string;
|
||||||
|
recipient_id?: string;
|
||||||
|
icon_file_id?: string;
|
||||||
|
status?: string;
|
||||||
|
last_message_id?: string;
|
||||||
|
last_message_at?: string;
|
||||||
|
can_send?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** Снапшот READY в формате шлюза. */
|
/** Снапшот READY в формате шлюза. */
|
||||||
export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
|
export function makeReadySnapshot(
|
||||||
|
guilds: SnapshotGuildInput[] = [],
|
||||||
|
dmChannels: SnapshotDmInput[] = [],
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
@@ -237,7 +252,22 @@ export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
|
|||||||
my_role_ids: [],
|
my_role_ids: [],
|
||||||
my_permissions: guild.my_permissions ?? [],
|
my_permissions: guild.my_permissions ?? [],
|
||||||
})),
|
})),
|
||||||
dm_channels: [],
|
dm_channels: dmChannels.map((channel) => ({
|
||||||
|
id: channel.id,
|
||||||
|
type: 'dm',
|
||||||
|
name: channel.name,
|
||||||
|
position: 0,
|
||||||
|
...(channel.recipient_id === undefined ? {} : { recipient_id: channel.recipient_id }),
|
||||||
|
...(channel.icon_file_id === undefined ? {} : { icon_file_id: channel.icon_file_id }),
|
||||||
|
...(channel.status === undefined ? {} : { status: channel.status }),
|
||||||
|
...(channel.last_message_id === undefined
|
||||||
|
? {}
|
||||||
|
: { last_message_id: channel.last_message_id }),
|
||||||
|
...(channel.last_message_at === undefined
|
||||||
|
? {}
|
||||||
|
: { last_message_at: channel.last_message_at }),
|
||||||
|
can_send: channel.can_send ?? true,
|
||||||
|
})),
|
||||||
read_states: [],
|
read_states: [],
|
||||||
session_id: 'session-1',
|
session_id: 'session-1',
|
||||||
heartbeat_interval_ms: 45_000,
|
heartbeat_interval_ms: 45_000,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import en from '@/i18n/locales/en.json';
|
||||||
|
import ru from '@/i18n/locales/ru.json';
|
||||||
|
|
||||||
|
/** Плоский список пар «ключ → значение». */
|
||||||
|
function flatEntries(value: unknown, prefix = ''): [string, unknown][] {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const entries: [string, unknown][] = [];
|
||||||
|
for (const [key, nested] of Object.entries(value)) {
|
||||||
|
const path = prefix === '' ? key : `${prefix}.${key}`;
|
||||||
|
if (typeof nested === 'object' && nested !== null) {
|
||||||
|
entries.push(...flatEntries(nested, path));
|
||||||
|
} else {
|
||||||
|
entries.push([path, nested]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Плоский список ключей вида `friends.empty.title`. */
|
||||||
|
function flatKeys(value: unknown): string[] {
|
||||||
|
return flatEntries(value)
|
||||||
|
.map(([key]) => key)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Паритет переводов: русский — основной, английский должен повторять его
|
||||||
|
* ключ в ключ, иначе часть интерфейса останется без строки.
|
||||||
|
*/
|
||||||
|
describe('переводы', () => {
|
||||||
|
it('ключи ru и en совпадают', () => {
|
||||||
|
expect(flatKeys(en)).toEqual(flatKeys(ru));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нет пустых строк', () => {
|
||||||
|
const empty = flatEntries(ru)
|
||||||
|
.filter(([, value]) => typeof value === 'string' && value.trim() === '')
|
||||||
|
.map(([key]) => key);
|
||||||
|
expect(empty).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,9 +76,9 @@ describe('страница входа', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(router.state.location.pathname).toBe('/app/empty');
|
expect(router.state.location.pathname).toBe('/app/empty');
|
||||||
});
|
});
|
||||||
expect(
|
expect(await screen.findByRole('heading', { name: 'У вас пока нет серверов' })).toBeVisible();
|
||||||
await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
|
// Автовступления в «главный сервер» больше нет: вход ведёт на пустой экран.
|
||||||
).toBeVisible();
|
expect(findRequest(fetchMock, { url: '/guilds/g-main/join', method: 'POST' })).toBeUndefined();
|
||||||
|
|
||||||
expect(findRequest(fetchMock, { url: '/auth/login', method: 'POST' })?.body).toEqual({
|
expect(findRequest(fetchMock, { url: '/auth/login', method: 'POST' })?.body).toEqual({
|
||||||
email: 'alice@example.com',
|
email: 'alice@example.com',
|
||||||
|
|||||||
+23
-57
@@ -4,6 +4,7 @@ import { vi } from 'vitest';
|
|||||||
|
|
||||||
import { GatewayOp } from '@/api/gateway';
|
import { GatewayOp } from '@/api/gateway';
|
||||||
import {
|
import {
|
||||||
|
findRequest,
|
||||||
installFetch,
|
installFetch,
|
||||||
json,
|
json,
|
||||||
makeReadySnapshot,
|
makeReadySnapshot,
|
||||||
@@ -227,8 +228,8 @@ describe('основной экран', () => {
|
|||||||
expect(screen.queryByLabelText('Создать комнату')).toBeNull();
|
expect(screen.queryByLabelText('Создать комнату')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('пустой список серверов предлагает главный сервер и создание своего', async () => {
|
it('пустой список серверов предлагает создать свой и вступить по приглашению', async () => {
|
||||||
installFetch([
|
const fetchMock = installFetch([
|
||||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||||
{
|
{
|
||||||
match: '/api/v1/instance',
|
match: '/api/v1/instance',
|
||||||
@@ -249,69 +250,34 @@ describe('основной экран', () => {
|
|||||||
|
|
||||||
renderApp('/app/empty');
|
renderApp('/app/empty');
|
||||||
|
|
||||||
expect(
|
expect(await screen.findByText('У вас пока нет серверов')).toBeVisible();
|
||||||
await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
|
|
||||||
).toBeVisible();
|
|
||||||
expect(screen.getByLabelText('Создать сервер')).toBeVisible();
|
expect(screen.getByLabelText('Создать сервер')).toBeVisible();
|
||||||
|
expect(await screen.findByTestId('invite-join')).toBeVisible();
|
||||||
|
// Автовступления в «главный сервер» больше нет: только по приглашению.
|
||||||
|
expect(findRequest(fetchMock, { url: '/guilds/g-main/join', method: 'POST' })).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('после вступления в главный сервер открывает его в URL', async () => {
|
it('друзья — первая иконка рейки, выше серверов', async () => {
|
||||||
const { default: userEvent } = await import('@testing-library/user-event');
|
|
||||||
const visitor = userEvent.setup();
|
|
||||||
const mainGuild = {
|
|
||||||
id: 'g-main',
|
|
||||||
name: 'Главный',
|
|
||||||
owner_id: 'user-1',
|
|
||||||
is_main: true,
|
|
||||||
member_count: 5,
|
|
||||||
my_role_ids: [],
|
|
||||||
my_permissions: [],
|
|
||||||
};
|
|
||||||
let joined = false;
|
|
||||||
installFetch([
|
installFetch([
|
||||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||||
{
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
match: '/api/v1/instance',
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||||
response: () =>
|
|
||||||
json({
|
|
||||||
instance: {
|
|
||||||
name: 'glchat-test',
|
|
||||||
registration_enabled: true,
|
|
||||||
allow_guild_creation: true,
|
|
||||||
max_guilds_per_user: 5,
|
|
||||||
main_guild_id: 'g-main',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
match: '/api/v1/guilds/g-main/join',
|
|
||||||
method: 'POST',
|
|
||||||
response: () => {
|
|
||||||
joined = true;
|
|
||||||
return json({ ok: true });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
match: '/api/v1/users/@me/guilds',
|
|
||||||
response: () => json({ guilds: joined ? [mainGuild] : [] }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
match: '/api/v1/guilds/g-main/channels',
|
|
||||||
response: () =>
|
|
||||||
json({ channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }] }),
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
const gateway = installGatewaySocket(snapshot);
|
||||||
|
|
||||||
const { router } = renderApp('/app/empty');
|
renderApp('/app/g-1/c-general');
|
||||||
await visitor.click(
|
await screen.findByTestId('guild-rail');
|
||||||
await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
|
await gateway.greet();
|
||||||
|
|
||||||
|
// Друзья идут выше списка серверов, между ними — разделитель.
|
||||||
|
const rail = screen.getByTestId('guild-rail');
|
||||||
|
expect(screen.getByTestId('friends-rail-link').compareDocumentPosition(rail)).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
);
|
);
|
||||||
|
expect(within(rail).getAllByRole('link')).toHaveLength(2);
|
||||||
// В снапшоте сервера может не быть — REST-подстраховка обновляет список,
|
expect(screen.getByRole('separator')).toBeVisible();
|
||||||
// после чего пользователь попадает в первую комнату сервера.
|
// Личные беседы в рейку не попадают: там только раздел друзей и серверы.
|
||||||
await waitFor(() => {
|
expect(screen.getByRole('link', { name: 'Друзья' })).toHaveAttribute('href', '/app/friends');
|
||||||
expect(router.state.location.pathname).toStrictEqual(expect.stringContaining('/app/g-main'));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('создаёт сервер через модальное окно', async () => {
|
it('создаёт сервер через модальное окно', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user