Files
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

355 lines
12 KiB
TypeScript

import type { IMessage } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const mockMeteorError = class extends Error {
constructor(
public error: string,
public reason: string,
public details: any,
) {
super(reason);
this.name = 'Meteor.Error';
}
};
const messagesMock = {
findForUpdates: sinon.stub(),
trashFindDeletedAfter: sinon.stub(),
trashFind: sinon.stub(),
};
const canAccessRoomIdAsyncStub = sinon.stub().resolves(true);
const getChannelHistoryStub = sinon.stub();
const {
extractTimestampFromCursor,
mountCursorQuery,
mountCursorFromMessage,
mountNextCursor,
mountPreviousCursor,
handleWithoutPagination,
handleCursorPagination,
getMessageHistory,
} = proxyquire.noCallThru().load('../../../../server/publications/messages', {
'meteor/check': {
check: sinon.stub(),
},
'../lib/authorization/canAccessRoom': {
canAccessRoomIdAsync: canAccessRoomIdAsyncStub,
},
'../meteor-methods/messages/getChannelHistory': {
getChannelHistory: getChannelHistoryStub,
},
'meteor/meteor': {
'Meteor': {
methods: sinon.stub(),
Error: mockMeteorError,
},
'@global': true,
},
'@rocket.chat/models': {
Messages: messagesMock,
},
});
describe('extractTimestampFromCursor', () => {
it('should return the correct timestamp', () => {
const cursor = new Date('2024-01-01T00:00:00.000Z').getTime().toString();
const timestamp = extractTimestampFromCursor(cursor);
expect(timestamp).to.be.an.instanceOf(Date);
expect(timestamp.getTime()).to.equal(parseInt(cursor, 10));
});
it('should handle non-date compliant string', () => {
expect(() => extractTimestampFromCursor('not-a-date')).to.throw(Error, 'Invalid Date');
});
});
describe('mountCursorQuery', () => {
const mockDate = new Date('2024-01-01T00:00:00.000Z').getTime();
it('should return a query with $gt when next is provided', () => {
const result = mountCursorQuery({ next: mockDate.toString() });
expect(result.query).to.deep.equal({ $gt: new Date(mockDate) });
});
it('should return a query with $lt when previous is provided', () => {
const result = mountCursorQuery({ previous: mockDate.toString() });
expect(result.query).to.deep.equal({ $lt: new Date(mockDate) });
});
it('should return a query with $gt and sort when neither next nor previous is provided', () => {
const result = mountCursorQuery({ count: 10 });
expect(result.query).to.deep.equal({ $gt: new Date(0) });
expect(result.options).to.deep.equal({ sort: { _updatedAt: 1 } });
});
it('should return a query with $gt and ascending sort when next is provided', () => {
const result = mountCursorQuery({ next: mockDate.toString(), count: 10 });
expect(result.query).to.deep.equal({ $gt: new Date(mockDate) });
expect(result.options).to.deep.equal({ sort: { _updatedAt: 1 }, limit: 10 + 1 });
});
it('should return a query with $gt and descending sort when previous is provided', () => {
const result = mountCursorQuery({ previous: mockDate.toString(), count: 10 });
expect(result.query).to.deep.equal({ $lt: new Date(mockDate) });
expect(result.options).to.deep.equal({ sort: { _updatedAt: -1 }, limit: 10 + 1 });
});
});
describe('mountCursorFromMessage', () => {
it('should return the updated timestamp for UPDATED type', () => {
const message: Pick<IMessage, '_updatedAt'> = {
_updatedAt: new Date('2024-01-01T00:00:00Z'),
};
const result = mountCursorFromMessage(message, 'UPDATED');
expect(result).to.equal(`${message._updatedAt.getTime()}`);
});
it('should return the deleted timestamp for DELETED type', () => {
const message: Partial<IMessage> & { _deletedAt: Date } = {
_deletedAt: new Date('2024-01-01T00:00:00Z'),
};
const result = mountCursorFromMessage(message, 'DELETED');
expect(result).to.equal(`${message._deletedAt.getTime()}`);
});
it('should throw an error if DELETED type and _deletedAt is not present', () => {
const message: Pick<IMessage, '_updatedAt'> = {
_updatedAt: new Date('2024-01-01T00:00:00Z'),
};
expect(() => mountCursorFromMessage(message, 'DELETED')).to.throw(mockMeteorError, 'Cursor not found');
});
});
describe('mountNextCursor', () => {
const mockMessage = (timestamp: number): Pick<IMessage, '_id' | 'ts' | '_updatedAt'> => ({
_id: '1',
ts: new Date(timestamp),
_updatedAt: new Date(timestamp),
});
it('should return null if messages array is empty', () => {
expect(mountNextCursor([], 10, 'UPDATED')).to.be.null;
});
it('should return the first message cursor if previous is provided', () => {
const messages = [mockMessage(1000), mockMessage(2000)];
expect(mountNextCursor(messages, 10, 'UPDATED', undefined, 'prev')).to.equal('1000');
});
it('should return null if messages length is less than or equal to count and next is provided', () => {
const messages = [mockMessage(1000), mockMessage(2000)];
expect(mountNextCursor(messages, 2, 'UPDATED', 'next')).to.be.null;
});
it('should return the second last message cursor if messages length is greater than count and next is provided', () => {
const messages = [mockMessage(1000), mockMessage(2000), mockMessage(3000)];
expect(mountNextCursor(messages, 2, 'UPDATED', 'next')).to.equal('2000');
});
it('should return the last message cursor if no next or previous is provided', () => {
const messages = [mockMessage(1000), mockMessage(2000)];
expect(mountNextCursor(messages, 10, 'UPDATED')).to.equal('2000');
});
});
describe('mountPreviousCursor', () => {
const mockMessage = (timestamp: number): Pick<IMessage, '_id' | 'ts' | '_updatedAt'> => ({
_id: '1',
ts: new Date(timestamp),
_updatedAt: new Date(timestamp),
});
it('should return null if messages array is empty', () => {
expect(mountPreviousCursor([], 10, 'UPDATED')).to.be.null;
});
it('should return the first message cursor if messages length is less than or equal to count and next is provided', () => {
const messages = [mockMessage(1000)];
expect(mountPreviousCursor(messages, 1, 'UPDATED', 'nextCursor')).to.equal('1000');
});
it('should return the first message cursor if messages length is greater than count and next is provided', () => {
const messages = [mockMessage(1000), mockMessage(2000)];
expect(mountPreviousCursor(messages, 1, 'UPDATED', 'nextCursor')).to.equal('1000');
});
it('should return null if messages length is less than or equal to count and previous is provided', () => {
const messages = [mockMessage(1000)];
expect(mountPreviousCursor(messages, 1, 'UPDATED', undefined, 'previousCursor')).to.be.null;
});
it('should return the second last message cursor if messages length is greater than count and previous is provided', () => {
const messages = [mockMessage(1000), mockMessage(2000), mockMessage(3000)];
expect(mountPreviousCursor(messages, 2, 'UPDATED', undefined, 'previousCursor')).to.equal('2000');
});
it('should return the first message cursor if no next or previous is provided', () => {
const messages = [mockMessage(1000)];
expect(mountPreviousCursor(messages, 1, 'UPDATED')).to.equal('1000');
});
});
describe('handleWithoutPagination', () => {
afterEach(() => {
messagesMock.findForUpdates.reset();
messagesMock.trashFindDeletedAfter.reset();
});
it('should return updated and deleted messages', async () => {
const rid = 'roomId';
const lastUpdate = new Date();
const updatedMessages = [{ _id: '1', text: 'Hello' }];
const deletedMessages = [{ _id: '2', _deletedAt: new Date() }];
messagesMock.findForUpdates.returns({ toArray: sinon.stub().resolves(updatedMessages) });
messagesMock.trashFindDeletedAfter.returns({ toArray: sinon.stub().resolves(deletedMessages) });
const result = await handleWithoutPagination(rid, lastUpdate);
expect(result).to.deep.equal({
updated: updatedMessages,
deleted: deletedMessages,
});
sinon.assert.calledWith(messagesMock.findForUpdates, rid, { updatedAt: { $gt: lastUpdate }, minTs: undefined }, { sort: { ts: -1 } });
sinon.assert.calledWith(
messagesMock.trashFindDeletedAfter,
lastUpdate,
{ rid },
{ projection: { _id: 1, _deletedAt: 1 }, sort: { ts: -1 } },
);
});
it('should bound both queries by `ts` when fromTs is provided', async () => {
const rid = 'roomId';
const lastUpdate = new Date('2024-01-02T00:00:00.000Z');
const fromTs = new Date('2024-01-01T00:00:00.000Z');
messagesMock.findForUpdates.returns({ toArray: sinon.stub().resolves([]) });
messagesMock.trashFindDeletedAfter.returns({ toArray: sinon.stub().resolves([]) });
await handleWithoutPagination(rid, lastUpdate, fromTs);
sinon.assert.calledWith(messagesMock.findForUpdates, rid, { updatedAt: { $gt: lastUpdate }, minTs: fromTs }, { sort: { ts: -1 } });
sinon.assert.calledWith(
messagesMock.trashFindDeletedAfter,
lastUpdate,
{ rid, ts: { $gte: fromTs } },
{ projection: { _id: 1, _deletedAt: 1 }, sort: { ts: -1 } },
);
});
it('should handle empty results', async () => {
const rid = 'roomId';
const lastUpdate = new Date();
messagesMock.findForUpdates.returns({ toArray: sinon.stub().resolves([]) });
messagesMock.trashFindDeletedAfter.returns({ toArray: sinon.stub().resolves([]) });
const result = await handleWithoutPagination(rid, lastUpdate);
expect(result).to.deep.equal({
updated: [],
deleted: [],
});
});
});
describe('handleCursorPagination', () => {
it('should return updated messages and cursor when type is UPDATED', async () => {
const rid = 'roomId';
const count = 10;
const messages = [{ _id: 'msg1', _updatedAt: new Date('2024-01-01T00:00:00Z') }];
messagesMock.findForUpdates.returns({ toArray: sinon.stub().resolves(messages) });
const result = await handleCursorPagination('UPDATED', rid, count);
expect(messagesMock.findForUpdates.calledOnce).to.be.true;
// the cursor path has no `ts` bound to apply — it paginates on `_updatedAt` alone
sinon.assert.calledWith(messagesMock.findForUpdates, rid, { updatedAt: { $gt: new Date(0) } }, { sort: { _updatedAt: 1 } });
expect(result.updated).to.deep.equal(messages);
expect(result.cursor).to.have.keys(['next', 'previous']);
});
it('should return deleted messages and cursor when type is DELETED', async () => {
const rid = 'roomId';
const count = 10;
const messages = [{ _id: 'msg1', _deletedAt: new Date() }];
messagesMock.trashFind.returns({ toArray: sinon.stub().resolves(messages) });
const result = await handleCursorPagination('DELETED', rid, count);
expect(messagesMock.trashFind.calledOnce).to.be.true;
expect(result.deleted).to.deep.equal(messages);
expect(result.cursor).to.have.keys(['next', 'previous']);
});
it('should handle empty response correctly', async () => {
const rid = 'roomId';
const count = 10;
messagesMock.findForUpdates.returns({ toArray: sinon.stub().resolves([]) });
const result = await handleCursorPagination('UPDATED', rid, count);
expect(result.updated).to.deep.equal([]);
expect(result.cursor).to.deep.equal({ next: null, previous: null });
});
});
describe('getMessageHistory', () => {
const rid = 'roomId';
const fromId = 'userId';
const catchError = async (promise: Promise<unknown>): Promise<any> => {
try {
await promise;
} catch (error) {
return error;
}
throw new Error('expected the call to be rejected');
};
beforeEach(() => {
messagesMock.findForUpdates.reset();
messagesMock.trashFindDeletedAfter.reset();
messagesMock.trashFind.reset();
getChannelHistoryStub.reset();
});
it('should reject "fromTs" when combined with cursor pagination', async () => {
const error = await catchError(getMessageHistory(rid, fromId, { fromTs: new Date(), type: 'UPDATED', next: '1704067200000' }));
expect(error).to.have.property('error', 'error-fromTs-requires-lastUpdate');
sinon.assert.notCalled(messagesMock.findForUpdates);
});
it('should reject "fromTs" when it would fall through to the channel history', async () => {
const error = await catchError(getMessageHistory(rid, fromId, { fromTs: new Date() }));
expect(error).to.have.property('error', 'error-fromTs-requires-lastUpdate');
sinon.assert.notCalled(getChannelHistoryStub);
});
it('should bound the query by "fromTs" when "lastUpdate" is provided', async () => {
const lastUpdate = new Date('2024-01-02T00:00:00.000Z');
const fromTs = new Date('2024-01-01T00:00:00.000Z');
messagesMock.findForUpdates.returns({ toArray: sinon.stub().resolves([]) });
messagesMock.trashFindDeletedAfter.returns({ toArray: sinon.stub().resolves([]) });
await getMessageHistory(rid, fromId, { lastUpdate, fromTs });
sinon.assert.calledWith(messagesMock.findForUpdates, rid, { updatedAt: { $gt: lastUpdate }, minTs: fromTs }, { sort: { ts: -1 } });
});
});