feat(web): чат Фазы 2 — лента, композер, реакции, ответы, поиск и вложения
Полноценный текстовый чат в веб-клиенте:
- стор сообщений по комнатам (кэш 200, оптимистичная отправка с nonce,
применение всех событий Gateway, пагинация курсором, непрочитанные и
упоминания, сброс при логауте и INVALID_SESSION);
- лента: группировка подряд идущих сообщений одного автора, разделители дат,
разделитель «новые сообщения», подгрузка истории вверх, пустые состояния;
- разметка: жирный/курсив/код/блок кода/зачёркнутый/цитата/ссылки (только
http(s)) и упоминания <@id> — безопасный рендер React-узлами без
dangerouslySetInnerHTML;
- реакции чипами с подсветкой своих, быстрые эмодзи и пикер, панель действий и
контекстное меню (ответ, реакция, закрепление, правка, удаление, копирование);
- ответы с переходом к оригиналу, inline-правка, «печатает…» с POST /typing раз
в 3 с, пины в модалке, поиск по комнате с подсветкой и переходом;
- композер: автовысота, Enter/Shift+Enter, лимит 4000, drag&drop и скрепка
(POST /channels/{id}/files → attachment_ids), обработка 429 с таймером и
perm.denied с причиной, read-only без SEND_MESSAGES;
- бейджи непрочитанных и упоминаний в сайдбаре, авто-POST /ack при активном окне;
- 176 тестов Vitest (было 100), check/lint/test/build зелёные.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Message } from '@/api/messages';
|
||||
import type { GuildMember, Role } from '@/api/types';
|
||||
import {
|
||||
GROUP_WINDOW_MS,
|
||||
buildChatRows,
|
||||
canGroup,
|
||||
compareMessageIds,
|
||||
copyText,
|
||||
dayKey,
|
||||
dayLabel,
|
||||
escapeSelector,
|
||||
formatMessageTime,
|
||||
memberDisplayName,
|
||||
roleColorForMember,
|
||||
} from '@/lib/chat';
|
||||
|
||||
const NOW = new Date(2026, 8, 19, 15, 0, 0);
|
||||
|
||||
function at(day: number, hour: number, minute: number): string {
|
||||
return new Date(2026, 8, day, hour, minute, 0).toISOString();
|
||||
}
|
||||
|
||||
function message(overrides: Partial<Message> & { id: string }): Message {
|
||||
return {
|
||||
channel_id: 'c-1',
|
||||
content: 'текст',
|
||||
type: 'default',
|
||||
pinned: false,
|
||||
attachments: [],
|
||||
mentions: [],
|
||||
reactions: [],
|
||||
created_at: at(19, 12, 0),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('группировка сообщений', () => {
|
||||
it('объединяет сообщения одного автора в пределах 5 минут', () => {
|
||||
const first = message({ id: 'm-1', author_id: 'user-1', created_at: at(19, 12, 0) });
|
||||
const second = message({ id: 'm-2', author_id: 'user-1', created_at: at(19, 12, 4) });
|
||||
expect(canGroup(first, second)).toBe(true);
|
||||
expect(
|
||||
canGroup(first, message({ id: 'm-3', author_id: 'user-1', created_at: at(19, 12, 6) })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('разрывает группу при смене автора, ответе и системном сообщении', () => {
|
||||
const first = message({ id: 'm-1', author_id: 'user-1', created_at: at(19, 12, 0) });
|
||||
expect(canGroup(first, message({ id: 'm-2', author_id: 'user-2' }))).toBe(false);
|
||||
expect(canGroup(first, message({ id: 'm-2', author_id: 'user-1', reply_to_id: 'm-9' }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(canGroup(first, message({ id: 'm-2', author_id: 'user-1', type: 'system' }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('строит строки ленты с разделителями дат', () => {
|
||||
const rows = buildChatRows(
|
||||
[
|
||||
message({ id: 'm-1', author_id: 'user-1', created_at: at(18, 22, 0) }),
|
||||
message({ id: 'm-2', author_id: 'user-1', created_at: at(19, 10, 0) }),
|
||||
message({ id: 'm-3', author_id: 'user-1', created_at: at(19, 10, 2) }),
|
||||
],
|
||||
{ unreadAnchorId: null },
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.kind)).toEqual(['date', 'group', 'date', 'group']);
|
||||
const second = rows[3];
|
||||
expect(second?.kind === 'group' ? second.group.messages.map((item) => item.id) : []).toEqual([
|
||||
'm-2',
|
||||
'm-3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ставит разделитель «новые сообщения» перед первым непрочитанным', () => {
|
||||
const rows = buildChatRows(
|
||||
[
|
||||
message({ id: 'm-1', author_id: 'user-1' }),
|
||||
message({ id: 'm-2', author_id: 'user-1' }),
|
||||
message({ id: 'm-3', author_id: 'user-2' }),
|
||||
],
|
||||
{ unreadAnchorId: 'm-1' },
|
||||
);
|
||||
|
||||
// Разделитель разрывает группу: m-2 попадает в новую группу после линии.
|
||||
expect(rows.map((row) => row.kind)).toEqual(['date', 'group', 'unread', 'group', 'group']);
|
||||
const unread = rows[3];
|
||||
expect(unread?.kind === 'group' ? unread.group.messages[0]?.id : null).toBe('m-2');
|
||||
});
|
||||
|
||||
it('считает «0» отметкой «комната не читалась»', () => {
|
||||
const rows = buildChatRows([message({ id: 'm-1', author_id: 'user-1' })], {
|
||||
unreadAnchorId: '0',
|
||||
});
|
||||
expect(rows.map((row) => row.kind)).toEqual(['date', 'unread', 'group']);
|
||||
});
|
||||
|
||||
it('не ставит разделитель, если отметка прочтения неизвестна', () => {
|
||||
const rows = buildChatRows([message({ id: 'm-1', author_id: 'user-1' })], {
|
||||
unreadAnchorId: 'm-404',
|
||||
});
|
||||
expect(rows.map((row) => row.kind)).toEqual(['date', 'group']);
|
||||
});
|
||||
|
||||
it('ставит разделитель перед окном, если отметка прочтения старше загруженного', () => {
|
||||
const rows = buildChatRows(
|
||||
[message({ id: '200', author_id: 'user-1' }), message({ id: '201', author_id: 'user-1' })],
|
||||
{ unreadAnchorId: '100' },
|
||||
);
|
||||
expect(rows.map((row) => row.kind)).toEqual(['date', 'unread', 'group']);
|
||||
});
|
||||
|
||||
it('сравнивает идентификаторы как снежинки', () => {
|
||||
expect(compareMessageIds('9', '10')).toBe(-1);
|
||||
expect(compareMessageIds('10', '10')).toBe(0);
|
||||
expect(compareMessageIds('pending:x', '10')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('даты сообщений', () => {
|
||||
it('различает сегодня, вчера и дату', () => {
|
||||
expect(dayLabel(at(19, 9, 0), NOW)).toBe('today');
|
||||
expect(dayLabel(at(18, 23, 0), NOW)).toBe('yesterday');
|
||||
expect(dayLabel(at(1, 12, 0), NOW)).toBe('date');
|
||||
});
|
||||
|
||||
it('даёт ключ локального дня', () => {
|
||||
expect(dayKey(at(19, 12, 0))).toBe('2026-09-19');
|
||||
});
|
||||
|
||||
it('форматирует время сообщения по локали', () => {
|
||||
expect(formatMessageTime(at(19, 9, 5), 'ru')).toMatch(/\d{1,2}:\d{2}/u);
|
||||
expect(formatMessageTime('не дата', 'ru')).toBe('');
|
||||
});
|
||||
|
||||
it('хранит окно группировки в 5 минут', () => {
|
||||
expect(GROUP_WINDOW_MS).toBe(300_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('авторы сообщений', () => {
|
||||
const roles: Role[] = [
|
||||
{
|
||||
id: 'r-low',
|
||||
name: 'Низкая',
|
||||
color: 0x00ff00,
|
||||
position: 1,
|
||||
permissions: '0',
|
||||
is_default: true,
|
||||
hoist: false,
|
||||
mentionable: false,
|
||||
},
|
||||
{
|
||||
id: 'r-high',
|
||||
name: 'Высокая',
|
||||
color: 0xff0000,
|
||||
position: 9,
|
||||
permissions: '0',
|
||||
is_default: false,
|
||||
hoist: true,
|
||||
mentionable: true,
|
||||
},
|
||||
];
|
||||
|
||||
const member: GuildMember = {
|
||||
user_id: 'user-1',
|
||||
username: 'alice',
|
||||
display_name: 'Alice',
|
||||
nickname: 'Аля',
|
||||
status: 'online',
|
||||
is_instance_admin: false,
|
||||
joined_at: '2026-09-01T10:00:00Z',
|
||||
role_ids: ['r-low', 'r-high'],
|
||||
};
|
||||
|
||||
it('берёт цвет самой высокой роли', () => {
|
||||
expect(roleColorForMember(member, roles)).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('игнорирует роли без цвета', () => {
|
||||
const colorless: Role[] = [roles[0] as Role, { ...(roles[1] as Role), color: 0 }];
|
||||
expect(roleColorForMember(member, colorless)).toBe('#00ff00');
|
||||
});
|
||||
|
||||
it('предпочитает ник участника', () => {
|
||||
expect(memberDisplayName(member)).toBe('Аля');
|
||||
expect(memberDisplayName({ ...member, nickname: '' })).toBe('Alice');
|
||||
expect(memberDisplayName(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('утилиты чата', () => {
|
||||
it('экранирует селекторы и не падает без CSS.escape', () => {
|
||||
const original = globalThis.CSS;
|
||||
// @ts-expect-error — проверяем запасную ветку без CSS.escape
|
||||
delete globalThis.CSS;
|
||||
try {
|
||||
expect(escapeSelector('pending:abc')).toBe('pending\\:abc');
|
||||
} finally {
|
||||
globalThis.CSS = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('копирование без clipboard не падает', async () => {
|
||||
await expect(copyText('текст')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
+169
-6
@@ -3,9 +3,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { GatewayOp } from '@/api/gateway';
|
||||
import type { Message } from '@/api/messages';
|
||||
import { routes } from '@/router';
|
||||
import { setQueryClient } from '@/lib/queryClient';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useMessagesStore } from '@/stores/messages';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import type { User } from '@/api/types';
|
||||
|
||||
@@ -110,13 +113,10 @@ export function findRequest(
|
||||
);
|
||||
}
|
||||
|
||||
/** Рендер приложения на заданном маршруте с изолированными сторами. */
|
||||
export function renderApp(initialPath = '/') {
|
||||
const queryClient = createTestQueryClient();
|
||||
// Стор шлюза обращается к общему клиенту (INVALID_SESSION → сброс кэша),
|
||||
// поэтому подменяем его на тестовый.
|
||||
setQueryClient(queryClient);
|
||||
/** Сброс всех сторов к исходному состоянию (изоляция между тестами). */
|
||||
export function resetStores(): void {
|
||||
useSessionStore.getState().reset();
|
||||
useMessagesStore.getState().reset();
|
||||
useGatewayStore.setState({
|
||||
status: 'idle',
|
||||
heartbeatIntervalMs: null,
|
||||
@@ -124,6 +124,15 @@ export function renderApp(initialPath = '/') {
|
||||
attempts: 0,
|
||||
invalidated: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Рендер приложения на заданном маршруте с изолированными сторами. */
|
||||
export function renderApp(initialPath = '/') {
|
||||
const queryClient = createTestQueryClient();
|
||||
// Стор шлюза обращается к общему клиенту (INVALID_SESSION → сброс кэша),
|
||||
// поэтому подменяем его на тестовый.
|
||||
setQueryClient(queryClient);
|
||||
resetStores();
|
||||
|
||||
const router = createMemoryRouter(routes, { initialEntries: [initialPath] });
|
||||
const view = render(
|
||||
@@ -225,3 +234,157 @@ export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
|
||||
heartbeat_interval_ms: 45_000,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Шлюз и сообщения Фазы 2 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Подставной WebSocket: рукопожатие и события под контролем теста. */
|
||||
class FakeGatewaySocket {
|
||||
static instances: FakeGatewaySocket[] = [];
|
||||
|
||||
readyState = 0;
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
onclose: ((event: CloseEvent) => void) | null = null;
|
||||
/** Кадры, отправленные клиентом (IDENTIFY, HEARTBEAT, RESUME). */
|
||||
readonly sent: { op: number; d: unknown }[] = [];
|
||||
|
||||
constructor(readonly url: string) {
|
||||
FakeGatewaySocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(raw: string): void {
|
||||
this.sent.push(JSON.parse(raw) as { op: number; d: unknown });
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = 3;
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = 1;
|
||||
this.onopen?.(new Event('open'));
|
||||
}
|
||||
|
||||
emit(packet: unknown): void {
|
||||
this.onmessage?.({ data: JSON.stringify(packet) } as MessageEvent);
|
||||
}
|
||||
|
||||
dispatch(type: string, payload: unknown, seq = 1): void {
|
||||
this.emit({ op: GatewayOp.DISPATCH, t: type, s: seq, d: payload });
|
||||
}
|
||||
}
|
||||
|
||||
export interface GatewayHarness {
|
||||
/** Открывает сокет, отвечает HELLO и присылает READY со снапшотом. */
|
||||
greet: () => Promise<void>;
|
||||
/** Событие DISPATCH от сервера. */
|
||||
dispatch: (type: string, payload: unknown) => void;
|
||||
/** Кадры, отправленные клиентом. */
|
||||
sent: () => { op: number; d: unknown }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Подменяет WebSocket подставным и отдаёт управление рукопожатием.
|
||||
* Снапшот READY уходит после `greet()`: до этого момента шлюз «подключается».
|
||||
*/
|
||||
export function installGatewaySocket(snapshot: unknown): GatewayHarness {
|
||||
FakeGatewaySocket.instances = [];
|
||||
vi.stubGlobal('WebSocket', FakeGatewaySocket);
|
||||
|
||||
const current = (): FakeGatewaySocket => {
|
||||
const socket = FakeGatewaySocket.instances[0];
|
||||
if (socket === undefined) {
|
||||
throw new Error('шлюз не подключился');
|
||||
}
|
||||
return socket;
|
||||
};
|
||||
|
||||
return {
|
||||
async greet() {
|
||||
await waitFor(() => {
|
||||
expect(FakeGatewaySocket.instances.length).toBeGreaterThan(0);
|
||||
});
|
||||
const socket = current();
|
||||
socket.open();
|
||||
socket.emit({
|
||||
op: GatewayOp.HELLO,
|
||||
d: { heartbeat_interval_ms: 45_000, session_id: 'session-1' },
|
||||
});
|
||||
socket.dispatch('READY', snapshot);
|
||||
},
|
||||
dispatch(type, payload) {
|
||||
current().dispatch(type, payload);
|
||||
},
|
||||
sent() {
|
||||
return current().sent;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Отложенный ответ: тест сам решает, когда сервер «ответит». */
|
||||
export interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
export function deferred<T>(): Deferred<T> {
|
||||
let settle!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
return { promise, resolve: (value) => settle(value) };
|
||||
}
|
||||
|
||||
/** Сообщение для моков и тестов стора. */
|
||||
export function makeMessage(
|
||||
overrides: Partial<Message> & { id: string; channel_id: string },
|
||||
): Message {
|
||||
return {
|
||||
content: 'привет',
|
||||
type: 'default',
|
||||
pinned: false,
|
||||
attachments: [],
|
||||
mentions: [],
|
||||
reactions: [],
|
||||
created_at: '2026-09-19T10:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Мок `GET /channels/{id}/messages` и других ручек комнаты. */
|
||||
export function messagesRoutes(
|
||||
channelId: string,
|
||||
messages: Message[],
|
||||
extra: FetchRoute[] = [],
|
||||
): FetchRoute[] {
|
||||
// Дополнительные маршруты идут первыми: сортировка в installFetch устойчива,
|
||||
// поэтому при одинаковом шаблоне выигрывает маршрут теста.
|
||||
return [
|
||||
...extra,
|
||||
{
|
||||
match: `/api/v1/channels/${channelId}/messages/search`,
|
||||
response: () => json({ messages: [] }),
|
||||
},
|
||||
{
|
||||
match: `/api/v1/channels/${channelId}/pins`,
|
||||
response: () => json({ messages: messages.filter((message) => message.pinned) }),
|
||||
},
|
||||
{
|
||||
match: `/api/v1/channels/${channelId}/messages`,
|
||||
response: () => json({ messages: [...messages].reverse() }),
|
||||
},
|
||||
{
|
||||
match: `/api/v1/channels/${channelId}/ack`,
|
||||
method: 'POST',
|
||||
response: () => json({ ok: true }),
|
||||
},
|
||||
{
|
||||
match: `/api/v1/channels/${channelId}/typing`,
|
||||
method: 'POST',
|
||||
response: () => json({ ok: true }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
deferred,
|
||||
installFetch,
|
||||
json,
|
||||
makeMessage,
|
||||
recordedRequests,
|
||||
resetStores,
|
||||
type FetchRoute,
|
||||
} from './helpers';
|
||||
import type { Message } from '@/api/messages';
|
||||
import { retryAfterMs } from '@/api/messages';
|
||||
import { channelState, unreadCount, useMessagesStore } from '@/stores/messages';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const CHANNEL = 'c-1';
|
||||
|
||||
/** Текущий пользователь: от него зависит автор оптимистичного сообщения. */
|
||||
function signIn(id = 'user-1'): void {
|
||||
useSessionStore.getState().setUser({
|
||||
id,
|
||||
username: 'alice',
|
||||
display_name: 'Alice',
|
||||
is_instance_admin: false,
|
||||
badges: [],
|
||||
});
|
||||
}
|
||||
|
||||
function state(channelId = CHANNEL) {
|
||||
return channelState(useMessagesStore.getState(), channelId);
|
||||
}
|
||||
|
||||
/** Последовательные сообщения с возрастающим временем. */
|
||||
function thread(
|
||||
count: number,
|
||||
overrides: (index: number) => Partial<Message> = () => ({}),
|
||||
): Message[] {
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
makeMessage({
|
||||
id: `m-${String(index + 1)}`,
|
||||
channel_id: CHANNEL,
|
||||
content: `сообщение ${String(index + 1)}`,
|
||||
author_id: 'user-1',
|
||||
created_at: new Date(Date.UTC(2026, 8, 19, 10, index)).toISOString(),
|
||||
...overrides(index),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function historyRoute(messages: Message[]): FetchRoute {
|
||||
return {
|
||||
match: `/api/v1/channels/${CHANNEL}/messages`,
|
||||
response: () => json({ messages: [...messages].reverse() }),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
signIn();
|
||||
});
|
||||
|
||||
describe('стор сообщений: загрузка истории', () => {
|
||||
it('загружает последнюю страницу и хранит сообщения по возрастанию времени', async () => {
|
||||
const messages = thread(3);
|
||||
installFetch([historyRoute(messages)]);
|
||||
|
||||
await useMessagesStore.getState().ensureChannel(CHANNEL);
|
||||
|
||||
const current = state();
|
||||
expect(current.status).toBe('ready');
|
||||
expect(current.messages.map((message) => message.id)).toEqual(['m-1', 'm-2', 'm-3']);
|
||||
expect(current.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('подгружает историю вверх по курсору before и останавливается на короткой странице', async () => {
|
||||
const messages = thread(50);
|
||||
const older = Array.from({ length: 4 }, (_, index) =>
|
||||
makeMessage({
|
||||
id: `old-${String(index + 1)}`,
|
||||
channel_id: CHANNEL,
|
||||
content: `старое ${String(index + 1)}`,
|
||||
created_at: new Date(Date.UTC(2026, 8, 18, 10, index)).toISOString(),
|
||||
}),
|
||||
);
|
||||
const fetchMock = installFetch([
|
||||
{
|
||||
match: `/api/v1/channels/${CHANNEL}/messages?limit=50&before=m-1`,
|
||||
response: () => json({ messages: [...older].reverse() }),
|
||||
},
|
||||
historyRoute(messages),
|
||||
]);
|
||||
|
||||
await useMessagesStore.getState().ensureChannel(CHANNEL);
|
||||
expect(state().hasMore).toBe(true);
|
||||
|
||||
await useMessagesStore.getState().loadMore(CHANNEL);
|
||||
|
||||
const current = state();
|
||||
expect(current.messages).toHaveLength(54);
|
||||
expect(current.messages[0]?.id).toBe('old-1');
|
||||
expect(current.hasMore).toBe(false);
|
||||
const requests = recordedRequests(fetchMock);
|
||||
expect(requests.at(-1)?.url).toContain('before=m-1');
|
||||
});
|
||||
|
||||
it('продолжает подгрузку истории, когда кэш упёрся в лимит', async () => {
|
||||
/** Страница по 50 сообщений: id убывают вглубь истории. */
|
||||
const cursorPath = (cursor: string): string =>
|
||||
`/api/v1/channels/${CHANNEL}/messages?limit=50&before=${cursor}`;
|
||||
const page = (from: number, count = 50): Message[] =>
|
||||
Array.from({ length: count }, (_, index) =>
|
||||
makeMessage({
|
||||
id: String(from + index),
|
||||
channel_id: CHANNEL,
|
||||
content: `сообщение ${String(from + index)}`,
|
||||
created_at: new Date(Date.UTC(2026, 8, 19, 10, 0, from + index)).toISOString(),
|
||||
}),
|
||||
);
|
||||
const fetchMock = installFetch([
|
||||
{ match: cursorPath('451'), response: () => json({ messages: [...page(401)].reverse() }) },
|
||||
{ match: cursorPath('401'), response: () => json({ messages: [...page(351)].reverse() }) },
|
||||
{ match: cursorPath('351'), response: () => json({ messages: [...page(301)].reverse() }) },
|
||||
{ match: cursorPath('301'), response: () => json({ messages: [...page(251)].reverse() }) },
|
||||
{ match: cursorPath('251'), response: () => json({ messages: [...page(249, 2)].reverse() }) },
|
||||
{
|
||||
match: `/api/v1/channels/${CHANNEL}/messages`,
|
||||
response: () => json({ messages: [...page(451)].reverse() }),
|
||||
},
|
||||
]);
|
||||
|
||||
await useMessagesStore.getState().ensureChannel(CHANNEL);
|
||||
expect(state().messages).toHaveLength(50);
|
||||
|
||||
// Четыре страницы упираются в лимит кэша, но курсор продолжает двигаться.
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await useMessagesStore.getState().loadMore(CHANNEL);
|
||||
}
|
||||
expect(state().messages).toHaveLength(200);
|
||||
expect(state().pagingCursor).toBe('251');
|
||||
expect(state().messages[0]?.id).toBe('251');
|
||||
expect(state().hasMore).toBe(true);
|
||||
|
||||
// Короткая страница закрывает историю: дальше запросов нет.
|
||||
await useMessagesStore.getState().loadMore(CHANNEL);
|
||||
expect(state().hasMore).toBe(false);
|
||||
const before = recordedRequests(fetchMock).filter((request) => request.url.includes('before='));
|
||||
expect(before.map((request) => request.url.split('before=')[1])).toEqual([
|
||||
'451',
|
||||
'401',
|
||||
'351',
|
||||
'301',
|
||||
'251',
|
||||
]);
|
||||
|
||||
const calls = recordedRequests(fetchMock).length;
|
||||
await useMessagesStore.getState().loadMore(CHANNEL);
|
||||
expect(recordedRequests(fetchMock)).toHaveLength(calls);
|
||||
});
|
||||
|
||||
it('не теряет пин, если сообщения нет в загруженной ленте', async () => {
|
||||
const pinned = makeMessage({ id: 'm-9', channel_id: CHANNEL, content: 'старое', pinned: true });
|
||||
installFetch([
|
||||
{ match: `/api/v1/channels/${CHANNEL}/pins`, response: () => json({ messages: [pinned] }) },
|
||||
]);
|
||||
|
||||
await useMessagesStore.getState().loadPins(CHANNEL);
|
||||
expect(state().pins).toHaveLength(1);
|
||||
|
||||
// Событие о повторном закреплении сообщения вне окна не должно убирать пин.
|
||||
useMessagesStore.getState().applyPinsUpdate(CHANNEL, 'm-9', true);
|
||||
expect(state().pins.map((item) => item.id)).toEqual(['m-9']);
|
||||
|
||||
useMessagesStore.getState().applyPinsUpdate(CHANNEL, 'm-9', false);
|
||||
expect(state().pins).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('сохраняет мой флаг реакции при MESSAGE_UPDATE от другого пользователя', () => {
|
||||
useMessagesStore.getState().applyMessageCreate(
|
||||
makeMessage({
|
||||
id: 'm-1',
|
||||
channel_id: CHANNEL,
|
||||
content: 'текст',
|
||||
reactions: [{ emoji: '👍', count: 2, me: true, user_ids: ['user-1', 'user-2'] }],
|
||||
}),
|
||||
);
|
||||
|
||||
// Payload события собран для автора правки: у него `me` относится к нему.
|
||||
useMessagesStore.getState().applyMessageUpdate(
|
||||
makeMessage({
|
||||
id: 'm-1',
|
||||
channel_id: CHANNEL,
|
||||
content: 'текст (правка)',
|
||||
edited_at: '2026-09-19T11:00:00Z',
|
||||
reactions: [
|
||||
{ emoji: '👍', count: 2, me: false, user_ids: ['user-1', 'user-2'] },
|
||||
{ emoji: '🔥', count: 1, me: true, user_ids: ['user-3'] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const reactions = state().messages[0]?.reactions ?? [];
|
||||
expect(reactions.find((item) => item.emoji === '👍')?.me).toBe(true);
|
||||
// Новую реакцию берём из payload: локального мнения о ней нет.
|
||||
expect(reactions.find((item) => item.emoji === '🔥')?.me).toBe(true);
|
||||
});
|
||||
|
||||
it('не считает своё эхо реакции дважды, когда сервер не прислал user_ids', () => {
|
||||
useMessagesStore.getState().applyMessageCreate(
|
||||
makeMessage({
|
||||
id: 'm-1',
|
||||
channel_id: CHANNEL,
|
||||
reactions: [{ emoji: '👍', count: 1, me: false }],
|
||||
}),
|
||||
);
|
||||
|
||||
useMessagesStore.getState().applyReaction(CHANNEL, 'm-1', '👍', 'user-1', true);
|
||||
useMessagesStore.getState().applyReaction(CHANNEL, 'm-1', '👍', 'user-1', true);
|
||||
expect(state().messages[0]?.reactions[0]?.count).toBe(2);
|
||||
|
||||
useMessagesStore.getState().applyReaction(CHANNEL, 'm-1', '👍', 'user-1', false);
|
||||
useMessagesStore.getState().applyReaction(CHANNEL, 'm-1', '👍', 'user-1', false);
|
||||
expect(state().messages[0]?.reactions[0]?.count).toBe(1);
|
||||
});
|
||||
|
||||
it('ограничивает кэш комнаты 200 сообщениями', () => {
|
||||
const store = useMessagesStore.getState();
|
||||
for (const message of thread(210)) {
|
||||
store.applyMessageCreate(message);
|
||||
}
|
||||
const current = state();
|
||||
expect(current.messages).toHaveLength(200);
|
||||
// Остаются самые новые.
|
||||
expect(current.messages.at(-1)?.id).toBe('m-210');
|
||||
expect(current.messages[0]?.id).toBe('m-11');
|
||||
});
|
||||
|
||||
it('сохраняет ошибку загрузки и повторяет запрос по требованию', async () => {
|
||||
let fail = true;
|
||||
installFetch([
|
||||
{
|
||||
match: `/api/v1/channels/${CHANNEL}/messages`,
|
||||
response: () =>
|
||||
fail
|
||||
? json({ error: { code: 'internal.error', message: 'boom' } }, 500)
|
||||
: json({ messages: [] }),
|
||||
},
|
||||
]);
|
||||
|
||||
await useMessagesStore.getState().ensureChannel(CHANNEL);
|
||||
expect(state().status).toBe('error');
|
||||
|
||||
fail = false;
|
||||
await useMessagesStore.getState().reloadChannel(CHANNEL);
|
||||
expect(state().status).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('стор сообщений: отправка', () => {
|
||||
it('добавляет оптимистичное сообщение и заменяет его ответом сервера', async () => {
|
||||
const created = makeMessage({
|
||||
id: 'm-9',
|
||||
channel_id: CHANNEL,
|
||||
content: 'привет',
|
||||
author_id: 'user-1',
|
||||
});
|
||||
const send = deferred<Response>();
|
||||
const fetchMock = installFetch([
|
||||
{
|
||||
match: `/api/v1/channels/${CHANNEL}/messages`,
|
||||
method: 'POST',
|
||||
response: () => send.promise,
|
||||
},
|
||||
]);
|
||||
|
||||
const sending = useMessagesStore.getState().send({ channelId: CHANNEL, content: 'привет' });
|
||||
|
||||
const optimistic = state().pending;
|
||||
expect(optimistic).toHaveLength(1);
|
||||
expect(optimistic[0]?.pending).toBe(true);
|
||||
expect(optimistic[0]?.author_id).toBe('user-1');
|
||||
expect(optimistic[0]?.content).toBe('привет');
|
||||
|
||||
send.resolve(json({ message: created }));
|
||||
await sending;
|
||||
|
||||
const current = state();
|
||||
expect(current.pending).toHaveLength(0);
|
||||
expect(current.messages.map((message) => message.id)).toEqual(['m-9']);
|
||||
const body = recordedRequests(fetchMock)[0]?.body as Record<string, unknown>;
|
||||
expect(body['content']).toBe('привет');
|
||||
expect(typeof body['nonce']).toBe('string');
|
||||
});
|
||||
|
||||
it('убирает оптимистичное сообщение, если сервер отказал', async () => {
|
||||
installFetch([
|
||||
{
|
||||
match: `/api/v1/channels/${CHANNEL}/messages`,
|
||||
method: 'POST',
|
||||
response: () => json({ error: { code: 'perm.denied', message: 'nope' } }, 403),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
useMessagesStore.getState().send({ channelId: CHANNEL, content: 'привет' }),
|
||||
).rejects.toThrow();
|
||||
expect(state().pending).toHaveLength(0);
|
||||
expect(state().messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('не дублирует сообщение, если событие шлюза пришло раньше ответа REST', async () => {
|
||||
const created = makeMessage({
|
||||
id: 'm-9',
|
||||
channel_id: CHANNEL,
|
||||
content: 'привет',
|
||||
author_id: 'user-1',
|
||||
});
|
||||
const send = deferred<Response>();
|
||||
installFetch([
|
||||
{
|
||||
match: `/api/v1/channels/${CHANNEL}/messages`,
|
||||
method: 'POST',
|
||||
response: () => send.promise,
|
||||
},
|
||||
]);
|
||||
|
||||
const sending = useMessagesStore.getState().send({ channelId: CHANNEL, content: 'привет' });
|
||||
useMessagesStore.getState().applyMessageCreate(created);
|
||||
|
||||
// Эхо шлюза сняло оптимистичное сообщение до ответа REST.
|
||||
expect(state().pending).toHaveLength(0);
|
||||
expect(state().messages.map((message) => message.id)).toEqual(['m-9']);
|
||||
|
||||
send.resolve(json({ message: created }));
|
||||
await sending;
|
||||
|
||||
expect(state().messages.map((message) => message.id)).toEqual(['m-9']);
|
||||
expect(state().pending).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('редактирует и удаляет сообщение с откатом при ошибке', async () => {
|
||||
useMessagesStore
|
||||
.getState()
|
||||
.applyMessageCreate(
|
||||
makeMessage({ id: 'm-1', channel_id: CHANNEL, content: 'старое', author_id: 'user-1' }),
|
||||
);
|
||||
installFetch([
|
||||
{
|
||||
match: `/api/v1/channels/${CHANNEL}/messages/m-1`,
|
||||
method: 'PATCH',
|
||||
response: () =>
|
||||
json({
|
||||
message: makeMessage({
|
||||
id: 'm-1',
|
||||
channel_id: CHANNEL,
|
||||
content: 'новое',
|
||||
author_id: 'user-1',
|
||||
edited_at: '2026-09-19T11:00:00Z',
|
||||
}),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
await useMessagesStore.getState().edit(CHANNEL, 'm-1', 'новое');
|
||||
expect(state().messages[0]?.content).toBe('новое');
|
||||
expect(state().messages[0]?.edited_at).toBe('2026-09-19T11:00:00Z');
|
||||
});
|
||||
|
||||
it('возвращает прежний текст, если правка не удалась', async () => {
|
||||
useMessagesStore
|
||||
.getState()
|
||||
.applyMessageCreate(
|
||||
makeMessage({ id: 'm-1', channel_id: CHANNEL, content: 'старое', author_id: 'user-1' }),
|
||||
);
|
||||
installFetch([
|
||||
{
|
||||
match: `/api/v1/channels/${CHANNEL}/messages/m-1`,
|
||||
method: 'PATCH',
|
||||
response: () =>
|
||||
json({ error: { code: 'message.edit_window_expired', message: 'late' } }, 403),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(useMessagesStore.getState().edit(CHANNEL, 'm-1', 'новое')).rejects.toThrow();
|
||||
expect(state().messages[0]?.content).toBe('старое');
|
||||
});
|
||||
|
||||
it('удаляет сообщение и возвращает его при ошибке', async () => {
|
||||
useMessagesStore
|
||||
.getState()
|
||||
.applyMessageCreate(
|
||||
makeMessage({ id: 'm-1', channel_id: CHANNEL, content: 'текст', author_id: 'user-1' }),
|
||||
);
|
||||
let fail = true;
|
||||
installFetch([
|
||||
{
|
||||
match: `/api/v1/channels/${CHANNEL}/messages/m-1`,
|
||||
method: 'DELETE',
|
||||
response: () =>
|
||||
fail
|
||||
? json({ error: { code: 'internal.error', message: 'x' } }, 500)
|
||||
: json({ ok: true }),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(useMessagesStore.getState().remove(CHANNEL, 'm-1')).rejects.toThrow();
|
||||
expect(state().messages).toHaveLength(1);
|
||||
|
||||
fail = false;
|
||||
await useMessagesStore.getState().remove(CHANNEL, 'm-1');
|
||||
expect(state().messages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('стор сообщений: реакции', () => {
|
||||
it('ставит реакцию через PUT и снимает через DELETE', async () => {
|
||||
useMessagesStore
|
||||
.getState()
|
||||
.applyMessageCreate(makeMessage({ id: 'm-1', channel_id: CHANNEL, author_id: 'user-2' }));
|
||||
const fetchMock = installFetch([
|
||||
{
|
||||
match: `/api/v1/channels/${CHANNEL}/messages/m-1/reactions/`,
|
||||
method: 'PUT',
|
||||
response: () => json({ ok: true }),
|
||||
},
|
||||
{
|
||||
match: `/api/v1/channels/${CHANNEL}/messages/m-1/reactions/`,
|
||||
method: 'DELETE',
|
||||
response: () => json({ ok: true }),
|
||||
},
|
||||
]);
|
||||
|
||||
await useMessagesStore.getState().toggleReaction(CHANNEL, 'm-1', '👍');
|
||||
// `user_ids` клиент ведёт сам: по нему отсекаются повторные события.
|
||||
expect(state().messages[0]?.reactions).toEqual([
|
||||
{ emoji: '👍', count: 1, me: true, user_ids: ['user-1'] },
|
||||
]);
|
||||
expect(recordedRequests(fetchMock)[0]?.url).toContain(`/reactions/${encodeURIComponent('👍')}`);
|
||||
|
||||
await useMessagesStore.getState().toggleReaction(CHANNEL, 'm-1', '👍');
|
||||
expect(state().messages[0]?.reactions).toEqual([]);
|
||||
});
|
||||
|
||||
it('откатывает реакцию, если запрос не удался', async () => {
|
||||
useMessagesStore
|
||||
.getState()
|
||||
.applyMessageCreate(makeMessage({ id: 'm-1', channel_id: CHANNEL, author_id: 'user-2' }));
|
||||
installFetch([
|
||||
{
|
||||
match: `/api/v1/channels/${CHANNEL}/messages/m-1/reactions/`,
|
||||
method: 'PUT',
|
||||
response: () => json({ error: { code: 'perm.denied', message: 'nope' } }, 403),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
useMessagesStore.getState().toggleReaction(CHANNEL, 'm-1', '🔥'),
|
||||
).rejects.toThrow();
|
||||
expect(state().messages[0]?.reactions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('стор сообщений: события шлюза', () => {
|
||||
it('MESSAGE_CREATE добавляет сообщение и не дублирует известное', () => {
|
||||
const message = makeMessage({ id: 'm-1', channel_id: CHANNEL, content: 'раз' });
|
||||
useMessagesStore.getState().applyMessageCreate(message);
|
||||
useMessagesStore.getState().applyMessageCreate(message);
|
||||
expect(state().messages).toHaveLength(1);
|
||||
|
||||
useMessagesStore
|
||||
.getState()
|
||||
.applyMessageCreate(makeMessage({ id: 'm-2', channel_id: CHANNEL, content: 'два' }));
|
||||
expect(state().messages.map((item) => item.id)).toEqual(['m-1', 'm-2']);
|
||||
});
|
||||
|
||||
it('MESSAGE_UPDATE заменяет содержимое, не меняя место в ленте', () => {
|
||||
useMessagesStore
|
||||
.getState()
|
||||
.applyMessageCreate(makeMessage({ id: 'm-1', channel_id: CHANNEL, content: 'старое' }));
|
||||
useMessagesStore.getState().applyMessageUpdate(
|
||||
makeMessage({
|
||||
id: 'm-1',
|
||||
channel_id: CHANNEL,
|
||||
content: 'новое',
|
||||
edited_at: '2026-09-19T12:00:00Z',
|
||||
}),
|
||||
);
|
||||
expect(state().messages).toHaveLength(1);
|
||||
expect(state().messages[0]?.content).toBe('новое');
|
||||
});
|
||||
|
||||
it('MESSAGE_DELETE убирает сообщение и его пин', () => {
|
||||
useMessagesStore
|
||||
.getState()
|
||||
.applyMessageCreate(makeMessage({ id: 'm-1', channel_id: CHANNEL, pinned: true }));
|
||||
useMessagesStore.getState().applyPinsUpdate(CHANNEL, 'm-1', true);
|
||||
expect(state().pins).toHaveLength(1);
|
||||
|
||||
useMessagesStore.getState().applyMessageDelete(CHANNEL, 'm-1');
|
||||
expect(state().messages).toHaveLength(0);
|
||||
expect(state().pins).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('MESSAGE_REACTION_ADD/REMOVE меняет счётчик и подсветку своей реакции', () => {
|
||||
useMessagesStore.getState().applyMessageCreate(
|
||||
makeMessage({
|
||||
id: 'm-1',
|
||||
channel_id: CHANNEL,
|
||||
reactions: [{ emoji: '👍', count: 1, me: false, user_ids: ['user-2'] }],
|
||||
}),
|
||||
);
|
||||
|
||||
useMessagesStore.getState().applyReaction(CHANNEL, 'm-1', '👍', 'user-1', true);
|
||||
expect(state().messages[0]?.reactions[0]).toEqual({
|
||||
emoji: '👍',
|
||||
count: 2,
|
||||
me: true,
|
||||
user_ids: ['user-2', 'user-1'],
|
||||
});
|
||||
|
||||
// Повторное событие того же пользователя счётчик не двигает.
|
||||
useMessagesStore.getState().applyReaction(CHANNEL, 'm-1', '👍', 'user-1', true);
|
||||
expect(state().messages[0]?.reactions[0]?.count).toBe(2);
|
||||
|
||||
useMessagesStore.getState().applyReaction(CHANNEL, 'm-1', '👍', 'user-1', false);
|
||||
expect(state().messages[0]?.reactions[0]).toEqual({
|
||||
emoji: '👍',
|
||||
count: 1,
|
||||
me: false,
|
||||
user_ids: ['user-2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('CHANNEL_PINS_UPDATE обновляет флаг и список пинов', () => {
|
||||
useMessagesStore
|
||||
.getState()
|
||||
.applyMessageCreate(makeMessage({ id: 'm-1', channel_id: CHANNEL, content: 'важное' }));
|
||||
useMessagesStore.getState().applyPinsUpdate(CHANNEL, 'm-1', true);
|
||||
expect(state().messages[0]?.pinned).toBe(true);
|
||||
expect(state().pins.map((message) => message.id)).toEqual(['m-1']);
|
||||
|
||||
useMessagesStore.getState().applyPinsUpdate(CHANNEL, 'm-1', false);
|
||||
expect(state().messages[0]?.pinned).toBe(false);
|
||||
expect(state().pins).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('TYPING_START показывает участника и гаснет по таймауту', () => {
|
||||
useMessagesStore.getState().applyTypingStart(CHANNEL, 'user-2');
|
||||
expect(state().typing.map((entry) => entry.user_id)).toEqual(['user-2']);
|
||||
|
||||
// Своё «печатает…» не показываем.
|
||||
useMessagesStore.getState().applyTypingStart(CHANNEL, 'user-1');
|
||||
expect(state().typing).toHaveLength(1);
|
||||
|
||||
useMessagesStore.getState().pruneTyping(Date.now() + 7_000);
|
||||
expect(state().typing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('READ_STATE_UPDATE задаёт отметку прочтения и счётчик упоминаний', () => {
|
||||
for (const message of thread(3)) {
|
||||
useMessagesStore.getState().applyMessageCreate(message);
|
||||
}
|
||||
useMessagesStore.getState().applyReadState({
|
||||
channel_id: CHANNEL,
|
||||
last_message_id: 'm-1',
|
||||
mention_count: 2,
|
||||
});
|
||||
|
||||
expect(state().lastReadMessageId).toBe('m-1');
|
||||
expect(state().mentionCount).toBe(2);
|
||||
expect(unreadCount(state())).toBe(2);
|
||||
});
|
||||
|
||||
it('READ_STATE_UPDATE помечает отметку прочтения известной', () => {
|
||||
expect(state().readStateKnown).toBe(false);
|
||||
useMessagesStore
|
||||
.getState()
|
||||
.applyReadState({ channel_id: CHANNEL, last_message_id: 'm-1', mention_count: 0 });
|
||||
expect(state().readStateKnown).toBe(true);
|
||||
|
||||
// Снапшот READY: после него отсутствие записи означает «не читалась».
|
||||
expect(useMessagesStore.getState().snapshotApplied).toBe(false);
|
||||
useMessagesStore.getState().markSnapshotApplied();
|
||||
expect(useMessagesStore.getState().snapshotApplied).toBe(true);
|
||||
useMessagesStore.getState().reset();
|
||||
expect(useMessagesStore.getState().snapshotApplied).toBe(false);
|
||||
});
|
||||
|
||||
it('READ_STATE_UPDATE без last_message_id не сбрасывает отметку прочтения', () => {
|
||||
useMessagesStore.getState().applyReadState({ channel_id: CHANNEL, mention_count: 0 });
|
||||
useMessagesStore.getState().applyReadState({
|
||||
channel_id: CHANNEL,
|
||||
last_message_id: 'm-5',
|
||||
mention_count: 1,
|
||||
});
|
||||
useMessagesStore.getState().applyReadState({ channel_id: CHANNEL, mention_count: 4 });
|
||||
|
||||
expect(state().lastReadMessageId).toBe('m-5');
|
||||
expect(state().mentionCount).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('стор сообщений: прочитанное и поиск', () => {
|
||||
it('acknowledge обнуляет упоминания и отправляет отметку на сервер', async () => {
|
||||
const fetchMock = installFetch([
|
||||
{
|
||||
match: `/api/v1/channels/${CHANNEL}/ack`,
|
||||
method: 'POST',
|
||||
response: () => json({ ok: true }),
|
||||
},
|
||||
]);
|
||||
useMessagesStore.getState().applyReadState({
|
||||
channel_id: CHANNEL,
|
||||
last_message_id: 'm-1',
|
||||
mention_count: 3,
|
||||
});
|
||||
|
||||
await useMessagesStore.getState().acknowledge(CHANNEL, 'm-9');
|
||||
|
||||
expect(state().lastReadMessageId).toBe('m-9');
|
||||
expect(state().mentionCount).toBe(0);
|
||||
const body = recordedRequests(fetchMock)[0]?.body as Record<string, unknown>;
|
||||
expect(body['last_message_id']).toBe('m-9');
|
||||
});
|
||||
|
||||
it('счётчик непрочитанных равен нулю без отметки прочтения', () => {
|
||||
for (const message of thread(3)) {
|
||||
useMessagesStore.getState().applyMessageCreate(message);
|
||||
}
|
||||
expect(unreadCount(state())).toBe(0);
|
||||
});
|
||||
|
||||
it('поиск складывает результаты и хранит ошибку лимита частоты', async () => {
|
||||
let limited = false;
|
||||
installFetch([
|
||||
{
|
||||
match: `/api/v1/channels/${CHANNEL}/messages/search`,
|
||||
response: () =>
|
||||
limited
|
||||
? json(
|
||||
{ error: { code: 'rate_limited', message: 'slow down', retry_after_ms: 2500 } },
|
||||
429,
|
||||
)
|
||||
: json({
|
||||
messages: [makeMessage({ id: 'm-1', channel_id: CHANNEL, content: 'найдено' })],
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
await useMessagesStore.getState().search(CHANNEL, 'най');
|
||||
expect(state().searchStatus).toBe('ready');
|
||||
expect(state().searchResults.map((message) => message.id)).toEqual(['m-1']);
|
||||
|
||||
limited = true;
|
||||
await useMessagesStore.getState().search(CHANNEL, 'най');
|
||||
expect(state().searchStatus).toBe('error');
|
||||
expect(retryAfterMs(state().searchError)).toBe(2500);
|
||||
});
|
||||
|
||||
it('типинг уходит не чаще раза в 3 секунды', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = installFetch([
|
||||
{
|
||||
match: `/api/v1/channels/${CHANNEL}/typing`,
|
||||
method: 'POST',
|
||||
response: () => json({ ok: true }),
|
||||
},
|
||||
]);
|
||||
try {
|
||||
useMessagesStore.getState().notifyTyping(CHANNEL);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
useMessagesStore.getState().notifyTyping(CHANNEL);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchMock.mock.calls).toHaveLength(1);
|
||||
|
||||
vi.setSystemTime(Date.now() + 3_100);
|
||||
useMessagesStore.getState().notifyTyping(CHANNEL);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchMock.mock.calls).toHaveLength(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
json,
|
||||
makeReadySnapshot,
|
||||
makeUser,
|
||||
messagesRoutes,
|
||||
recordedRequests,
|
||||
renderApp,
|
||||
} from './helpers';
|
||||
@@ -90,6 +91,9 @@ describe('основной экран', () => {
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
|
||||
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
||||
...messagesRoutes('c-general', []),
|
||||
]);
|
||||
const gateway = installGatewaySocket(snapshot);
|
||||
|
||||
@@ -118,8 +122,9 @@ describe('основной экран', () => {
|
||||
// Выход переехал во всплывающее меню (проверяется в tests/userMenu.test.tsx).
|
||||
expect(within(panel).getByLabelText('Открыть меню пользователя')).toBeVisible();
|
||||
|
||||
// Заглушка комнаты вместо чата Фазы 1: шапка комнаты + подсказка.
|
||||
expect(await screen.findByText('Чат появится в Фазе 2')).toBeVisible();
|
||||
// Фаза 2: вместо заглушки в области комнаты открывается чат.
|
||||
expect(await screen.findByTestId('composer')).toBeVisible();
|
||||
expect(await screen.findByTestId('chat-start')).toHaveTextContent('Это начало комнаты #общий');
|
||||
expect(screen.getByRole('heading', { name: 'общий' })).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user