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[] { 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(); 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; 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(); 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; 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(); } }); });