Files
upstream-rocketchat/apps/meteor/client/views/root/hooks/useLoadMissedMessages.spec.ts
upstream-mirror 30810eecd7
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

235 lines
8.1 KiB
TypeScript

import type { IMessage } from '@rocket.chat/core-typings';
import { useConnectionStatus } from '@rocket.chat/ui-contexts';
import { renderHook, waitFor } from '@testing-library/react';
import { useLoadMissedMessages } from './useLoadMissedMessages';
import { upsertMessageBulk } from '../../../lib/RoomHistoryManager';
import { sdk } from '../../../lib/SDKClient';
import { Messages } from '../../../stores/Messages';
import { Subscriptions } from '../../../stores/Subscriptions';
jest.mock('@rocket.chat/ui-contexts', () => ({
useConnectionStatus: jest.fn(),
}));
jest.mock('../../../lib/LegacyRoomManager', () => ({
LegacyRoomManager: { openedRooms: { 'room-id': { rid: 'room-id' } } },
}));
jest.mock('../../../lib/RoomHistoryManager', () => ({
upsertMessageBulk: jest.fn(),
}));
jest.mock('../../../lib/SDKClient', () => ({
sdk: { rest: { get: jest.fn() } },
}));
const rid = 'room-id';
const createMessage = (_id: string, ts: Date, extra: Partial<IMessage & { temp: boolean }> = {}): IMessage =>
({
_id,
rid,
msg: _id,
ts,
_updatedAt: ts,
u: { _id: 'user-id', username: 'user' },
...extra,
}) as IMessage;
/** Mimics what the API returns: the same shape as `createMessage`, but with dates serialized. */
const createSerializedMessage = (_id: string, ts: Date, extra: Record<string, unknown> = {}) => ({
_id,
rid,
msg: _id,
ts: ts.toISOString(),
_updatedAt: ts.toISOString(),
u: { _id: 'user-id', username: 'user' },
...extra,
});
const mockSyncResponse = ({ updated = [], deleted = [] }: { updated?: unknown[]; deleted?: { _id: string }[] } = {}) => {
(sdk.rest.get as jest.Mock).mockResolvedValue({ result: { updated, deleted } });
};
/** Renders the hook while offline, then flips the connection back on — the only transition that triggers a sync. */
const renderReconnection = () => {
(useConnectionStatus as jest.Mock).mockReturnValue({ connected: false });
const { rerender } = renderHook(() => useLoadMissedMessages());
(useConnectionStatus as jest.Mock).mockReturnValue({ connected: true });
rerender();
};
beforeEach(() => {
jest.clearAllMocks();
Messages.state.replaceAll([]);
Subscriptions.state.replaceAll([]);
mockSyncResponse();
// stand-in for the real bulk upsert, which only stores the messages it is handed
(upsertMessageBulk as jest.Mock).mockImplementation(async ({ msgs }: { msgs: IMessage[] }) => Messages.state.storeMany(msgs));
});
describe('useLoadMissedMessages', () => {
it('should bound the sync window by the oldest and newest loaded messages', async () => {
const oldest = new Date(1_000);
const newest = new Date(3_000);
Messages.state.storeMany([createMessage('oldest', oldest), createMessage('newest', newest)]);
renderReconnection();
await waitFor(() =>
expect(sdk.rest.get).toHaveBeenCalledWith('/v1/chat.syncMessages', {
roomId: rid,
lastUpdate: newest.toISOString(),
fromTs: oldest.toISOString(),
}),
);
});
it('should leave hidden and temp messages out of the sync window', async () => {
const oldest = new Date(2_000);
const newest = new Date(3_000);
Messages.state.storeMany([
createMessage('hidden', new Date(1_000), { _hidden: true }),
createMessage('oldest', oldest),
createMessage('newest', newest),
createMessage('temp', new Date(4_000), { temp: true }),
]);
renderReconnection();
await waitFor(() =>
expect(sdk.rest.get).toHaveBeenCalledWith('/v1/chat.syncMessages', {
roomId: rid,
lastUpdate: newest.toISOString(),
fromTs: oldest.toISOString(),
}),
);
});
it('should leave messages from other rooms out of the sync window', async () => {
const oldest = new Date(2_000);
const newest = new Date(3_000);
Messages.state.storeMany([
createMessage('other-older', new Date(1_000), { rid: 'other-room-id' }),
createMessage('oldest', oldest),
createMessage('newest', newest),
createMessage('other-newer', new Date(4_000), { rid: 'other-room-id' }),
]);
renderReconnection();
await waitFor(() =>
expect(sdk.rest.get).toHaveBeenCalledWith('/v1/chat.syncMessages', {
roomId: rid,
lastUpdate: newest.toISOString(),
fromTs: oldest.toISOString(),
}),
);
});
it('should not sync a room holding only hidden and temp messages', async () => {
Messages.state.storeMany([
createMessage('hidden', new Date(1_000), { _hidden: true }),
createMessage('temp', new Date(2_000), { temp: true }),
]);
renderReconnection();
await waitFor(() => expect(sdk.rest.get).not.toHaveBeenCalled());
});
it('should not sync while the connection stays online', async () => {
Messages.state.store(createMessage('message', new Date(1_000)));
(useConnectionStatus as jest.Mock).mockReturnValue({ connected: true });
const { rerender } = renderHook(() => useLoadMissedMessages());
rerender();
await waitFor(() => expect(sdk.rest.get).not.toHaveBeenCalled());
});
it('should merge the messages reported as updated into the store', async () => {
const ts = new Date(1_000);
const editedAt = new Date(2_000);
Messages.state.storeMany([createMessage('edited', ts), createMessage('untouched', ts)]);
mockSyncResponse({
updated: [
createSerializedMessage('edited', ts, { msg: 'edited text', editedAt: editedAt.toISOString() }),
createSerializedMessage('added', editedAt),
],
});
renderReconnection();
await waitFor(() => expect(Messages.state.get('edited')).toHaveProperty('msg', 'edited text'));
expect(Messages.state.get('edited')).toHaveProperty('editedAt', editedAt);
expect(Messages.state.get('added')).toHaveProperty('ts', editedAt);
expect(Messages.state.get('untouched')).toHaveProperty('msg', 'untouched');
});
it('should deserialize the dates of the messages reported as updated', async () => {
const ts = new Date(1_000);
const editedAt = new Date(2_000);
Messages.state.store(createMessage('edited', ts));
mockSyncResponse({ updated: [createSerializedMessage('edited', ts, { editedAt: editedAt.toISOString() })] });
renderReconnection();
await waitFor(() => expect(upsertMessageBulk).toHaveBeenCalled());
expect(upsertMessageBulk).toHaveBeenCalledWith(
expect.objectContaining({
msgs: [expect.objectContaining({ _id: 'edited', ts, _updatedAt: ts, editedAt })],
}),
);
});
it('should pass the room subscription along with the updated messages', async () => {
const ts = new Date(1_000);
const subscription = { _id: 'subscription-id', rid } as Parameters<typeof Subscriptions.state.store>[0];
Messages.state.store(createMessage('edited', ts));
Subscriptions.state.storeMany([subscription, { _id: 'other-subscription-id', rid: 'other-room-id' } as typeof subscription]);
mockSyncResponse({ updated: [createSerializedMessage('edited', ts)] });
renderReconnection();
await waitFor(() => expect(upsertMessageBulk).toHaveBeenCalledWith(expect.objectContaining({ subscription })));
});
it('should remove the messages reported as deleted', async () => {
Messages.state.storeMany([createMessage('kept', new Date(1_000)), createMessage('dropped', new Date(2_000))]);
mockSyncResponse({ deleted: [{ _id: 'dropped' }] });
renderReconnection();
await waitFor(() => expect(Messages.state.get('dropped')).toBeUndefined());
expect(Messages.state.get('kept')).toBeDefined();
});
it('should clear "tmid" from replies whose thread parent was deleted', async () => {
Messages.state.storeMany([createMessage('parent', new Date(1_000)), createMessage('reply', new Date(2_000), { tmid: 'parent' })]);
mockSyncResponse({ deleted: [{ _id: 'parent' }] });
renderReconnection();
await waitFor(() => expect(Messages.state.get('parent')).toBeUndefined());
expect(Messages.state.get('reply')).toBeDefined();
expect(Messages.state.get('reply')).not.toHaveProperty('tmid');
});
it('should keep "tmid" on replies whose thread parent survived', async () => {
Messages.state.storeMany([
createMessage('parent', new Date(1_000)),
createMessage('reply', new Date(2_000), { tmid: 'parent' }),
createMessage('dropped', new Date(3_000)),
]);
mockSyncResponse({ deleted: [{ _id: 'dropped' }] });
renderReconnection();
await waitFor(() => expect(Messages.state.get('dropped')).toBeUndefined());
expect(Messages.state.get('reply')).toHaveProperty('tmid', 'parent');
});
});