diff --git a/.changeset/empty-spiders-vanish.md b/.changeset/empty-spiders-vanish.md new file mode 100644 index 00000000000..8c7a62f6d2d --- /dev/null +++ b/.changeset/empty-spiders-vanish.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/ddp-streamer': patch +--- + +Fixes an issue where some DDP method calls could incorrectly return a 404 error. diff --git a/ee/apps/ddp-streamer/jest.config.ts b/ee/apps/ddp-streamer/jest.config.ts new file mode 100644 index 00000000000..46e41b9ab17 --- /dev/null +++ b/ee/apps/ddp-streamer/jest.config.ts @@ -0,0 +1,7 @@ +import server from '@rocket.chat/jest-presets/server'; +import type { Config } from 'jest'; + +export default { + preset: server.preset, + testMatch: ['/src/**/*.spec.(ts|js|mjs)'], +} satisfies Config; diff --git a/ee/apps/ddp-streamer/package.json b/ee/apps/ddp-streamer/package.json index 36ad0bea51a..1c57fb5ae60 100644 --- a/ee/apps/ddp-streamer/package.json +++ b/ee/apps/ddp-streamer/package.json @@ -15,7 +15,8 @@ "build": "tsc -p tsconfig.json", "lint": "eslint .", "ms": "TRANSPORTER=${TRANSPORTER:-TCP} MONGO_URL=${MONGO_URL:-mongodb://localhost:3001/meteor} ts-node --files src/service.ts", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "jest", + "testunit": "jest", "typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json" }, "dependencies": { @@ -50,7 +51,9 @@ "devDependencies": { "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/ddp-client": "workspace:~", + "@rocket.chat/jest-presets": "workspace:*", "@types/ejson": "^2.2.2", + "@types/jest": "~30.0.0", "@types/node": "~22.16.5", "@types/polka": "^0.5.8", "@types/prometheus-gc-stats": "^0.6.4", @@ -58,6 +61,7 @@ "@types/uuid": "^10.0.0", "@types/ws": "^8.18.1", "eslint": "~9.39.3", + "jest": "~30.2.0", "pino-pretty": "13.1.3", "ts-node": "^10.9.2", "typescript": "~5.9.3" diff --git a/ee/apps/ddp-streamer/src/Server.spec.ts b/ee/apps/ddp-streamer/src/Server.spec.ts new file mode 100644 index 00000000000..0f8bece0251 --- /dev/null +++ b/ee/apps/ddp-streamer/src/Server.spec.ts @@ -0,0 +1,108 @@ +import { MeteorService } from '@rocket.chat/core-services'; +import WebSocket from 'ws'; + +import { Server } from './Server'; +import type { IPacket } from './types/IPacket'; + +jest.mock('@rocket.chat/core-services', () => ({ + ...jest.requireActual('@rocket.chat/core-services'), + MeteorService: { + callMethodWithToken: jest.fn(), + }, +})); + +jest.mock('@rocket.chat/logger', () => ({ + Logger: jest.fn().mockReturnValue({ + error: jest.fn(), + }), +})); + +const mockCallMethodWithToken = jest.mocked(MeteorService.callMethodWithToken); + +function makeClient(readyState: number = WebSocket.OPEN) { + return { + ws: { readyState }, + userId: 'user1', + userToken: 'token1', + send: jest.fn(), + } as unknown as Parameters[0]; +} + +function makePacket(method: string, id = 'test-id'): IPacket { + return { msg: 'method', method, id, params: [] } as unknown as IPacket; +} + +describe('Server.call', () => { + let server: Server; + + beforeEach(() => { + server = new Server(); + jest.clearAllMocks(); + }); + + describe('when the method is delegated to MeteorService', () => { + it('returns the result value from MeteorService', async () => { + mockCallMethodWithToken.mockResolvedValue({ result: 'some-value' } as any); + const client = makeClient(); + const resultSpy = jest.spyOn(server, 'result'); + + await server.call(client, makePacket('someMethod')); + + expect(resultSpy).toHaveBeenCalledWith(client, expect.objectContaining({ id: 'test-id' }), 'some-value'); + }); + + it('does not return an error when the method returns void', async () => { + mockCallMethodWithToken.mockResolvedValue({ result: undefined } as any); + const client = makeClient(); + const resultSpy = jest.spyOn(server, 'result'); + + await server.call(client, makePacket('setAvatarFromService')); + + expect(resultSpy).toHaveBeenCalledWith(client, expect.objectContaining({ id: 'test-id' }), undefined); + }); + + it('calls result with an error when MeteorService throws', async () => { + mockCallMethodWithToken.mockRejectedValue(new Error('boom')); + const client = makeClient(); + const resultSpy = jest.spyOn(server, 'result'); + + await server.call(client, makePacket('someMethod')); + + expect(resultSpy).toHaveBeenCalledWith(client, expect.objectContaining({ id: 'test-id' }), null, expect.any(Error)); + }); + }); + + describe('when the method is registered locally', () => { + it('returns the result value from the local method', async () => { + server.methods({ localMethod: async () => 'local-result' }); + const client = makeClient(); + const resultSpy = jest.spyOn(server, 'result'); + + await server.call(client, makePacket('localMethod')); + + expect(resultSpy).toHaveBeenCalledWith(client, expect.objectContaining({ id: 'test-id' }), 'local-result'); + }); + + it('does not return an error when the local method returns void', async () => { + server.methods({ voidMethod: async () => undefined }); + const client = makeClient(); + const resultSpy = jest.spyOn(server, 'result'); + + await server.call(client, makePacket('voidMethod')); + + expect(resultSpy).toHaveBeenCalledWith(client, expect.objectContaining({ id: 'test-id' }), undefined); + }); + }); + + describe('when the client WebSocket is not open', () => { + it('does nothing', async () => { + const client = makeClient(WebSocket.CLOSED); + const resultSpy = jest.spyOn(server, 'result'); + + await server.call(client, makePacket('anyMethod')); + + expect(resultSpy).not.toHaveBeenCalled(); + expect(mockCallMethodWithToken).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ee/apps/ddp-streamer/src/Server.ts b/ee/apps/ddp-streamer/src/Server.ts index 2aaa028fab6..b5a2a6ae6b3 100644 --- a/ee/apps/ddp-streamer/src/Server.ts +++ b/ee/apps/ddp-streamer/src/Server.ts @@ -68,11 +68,7 @@ export class Server extends EventEmitter { // if method was not defined on DDP Streamer we fall back to Meteor if (!this._methods.has(packet.method)) { const result = await MeteorService.callMethodWithToken(client.userId, client.userToken, packet.method, packet.params); - if (result?.result) { - return this.result(client, packet, result.result); - } - - throw new MeteorError(404, `Method '${packet.method}' not found`); + return this.result(client, packet, result.result); } const fn = this._methods.get(packet.method); diff --git a/ee/apps/ddp-streamer/tsconfig.json b/ee/apps/ddp-streamer/tsconfig.json index 72e3c71e097..acd89a29157 100644 --- a/ee/apps/ddp-streamer/tsconfig.json +++ b/ee/apps/ddp-streamer/tsconfig.json @@ -2,7 +2,8 @@ "extends": "@rocket.chat/tsconfig/server.json", "compilerOptions": { "strictPropertyInitialization": false, // TODO: Remove this line - "outDir": "./dist" + "outDir": "./dist", + "types": ["jest", "node"] }, "include": ["./src/**/*", "./definition"], "exclude": ["./dist", "./ecosystem.config.js"] diff --git a/yarn.lock b/yarn.lock index 593c43b9c9c..a46ba00f1d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9248,6 +9248,7 @@ __metadata: "@rocket.chat/ddp-client": "workspace:~" "@rocket.chat/emitter": "npm:^0.32.0" "@rocket.chat/instance-status": "workspace:^" + "@rocket.chat/jest-presets": "workspace:*" "@rocket.chat/logger": "workspace:^" "@rocket.chat/model-typings": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -9256,6 +9257,7 @@ __metadata: "@rocket.chat/string-helpers": "npm:~0.32.0" "@rocket.chat/tracing": "workspace:^" "@types/ejson": "npm:^2.2.2" + "@types/jest": "npm:~30.0.0" "@types/node": "npm:~22.16.5" "@types/polka": "npm:^0.5.8" "@types/prometheus-gc-stats": "npm:^0.6.4" @@ -9267,6 +9269,7 @@ __metadata: eslint: "npm:~9.39.3" eventemitter3: "npm:^5.0.4" jaeger-client: "npm:^3.19.0" + jest: "npm:~30.2.0" mem: "npm:^8.1.1" moleculer: "npm:^0.14.35" mongodb: "npm:6.16.0" @@ -9671,7 +9674,7 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/jest-presets@workspace:^, @rocket.chat/jest-presets@workspace:packages/jest-presets, @rocket.chat/jest-presets@workspace:~": +"@rocket.chat/jest-presets@workspace:*, @rocket.chat/jest-presets@workspace:^, @rocket.chat/jest-presets@workspace:packages/jest-presets, @rocket.chat/jest-presets@workspace:~": version: 0.0.0-use.local resolution: "@rocket.chat/jest-presets@workspace:packages/jest-presets" dependencies: