685 lines
26 KiB
TypeScript
685 lines
26 KiB
TypeScript
|
|
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();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|