From 1101b94000daafc4101cc4f64dacbc0e796b01c5 Mon Sep 17 00:00:00 2001 From: Kristina Fefelova Date: Fri, 17 Oct 2025 17:23:28 +0400 Subject: [PATCH 1/5] Add tests Signed-off-by: Kristina Fefelova --- .github/workflows/ci.yml | 4 +- common/config/rush/pnpm-lock.yaml | 144 +- packages/rest-client/package.json | 2 +- packages/sdk-types/package.json | 2 +- packages/server/jest.config.js | 16 + packages/server/package.json | 7 +- packages/server/src/__mocks__/franc-min.ts | 3 + .../__mocks__/notification/notification.ts | 2 + packages/server/src/__mocks__/triggers/all.ts | 2 + packages/server/src/__tests__/blob.test.ts | 947 ++++++++++++++ packages/server/src/__tests__/client.test.ts | 458 +++++++ packages/server/src/__tests__/error.test.ts | 307 +++++ packages/server/src/__tests__/index.test.ts | 483 +++++++ .../server/src/__tests__/messageId.test.ts | 93 ++ .../server/src/__tests__/metadata.test.ts | 287 ++++ .../src/__tests__/middleware/base.test.ts | 375 ++++++ .../__tests__/middleware/broadcast.test.ts | 1071 +++++++++++++++ .../src/__tests__/middleware/date.test.ts | 249 ++++ .../src/__tests__/middleware/id.test.ts | 236 ++++ .../__tests__/middleware/indentity.test.ts | 332 +++++ .../src/__tests__/middleware/peer.test.ts | 323 +++++ .../__tests__/middleware/permissions.test.ts | 759 +++++++++++ .../src/__tests__/middleware/storage.test.ts | 1156 +++++++++++++++++ .../src/__tests__/middleware/triggers.test.ts | 891 +++++++++++++ .../src/__tests__/middleware/validate.test.ts | 1027 +++++++++++++++ .../server/src/__tests__/middlewares.test.ts | 644 +++++++++ .../notification/notification.test.ts | 872 +++++++++++++ packages/server/src/blob.ts | 36 +- packages/server/src/middleware/triggers.ts | 5 + packages/server/src/middleware/validate.ts | 15 + packages/server/tsconfig.json | 2 +- packages/types/package.json | 2 +- 32 files changed, 10724 insertions(+), 28 deletions(-) create mode 100644 packages/server/jest.config.js create mode 100644 packages/server/src/__mocks__/franc-min.ts create mode 100644 packages/server/src/__mocks__/notification/notification.ts create mode 100644 packages/server/src/__mocks__/triggers/all.ts create mode 100644 packages/server/src/__tests__/blob.test.ts create mode 100644 packages/server/src/__tests__/client.test.ts create mode 100644 packages/server/src/__tests__/error.test.ts create mode 100644 packages/server/src/__tests__/index.test.ts create mode 100644 packages/server/src/__tests__/messageId.test.ts create mode 100644 packages/server/src/__tests__/metadata.test.ts create mode 100644 packages/server/src/__tests__/middleware/base.test.ts create mode 100644 packages/server/src/__tests__/middleware/broadcast.test.ts create mode 100644 packages/server/src/__tests__/middleware/date.test.ts create mode 100644 packages/server/src/__tests__/middleware/id.test.ts create mode 100644 packages/server/src/__tests__/middleware/indentity.test.ts create mode 100644 packages/server/src/__tests__/middleware/peer.test.ts create mode 100644 packages/server/src/__tests__/middleware/permissions.test.ts create mode 100644 packages/server/src/__tests__/middleware/storage.test.ts create mode 100644 packages/server/src/__tests__/middleware/triggers.test.ts create mode 100644 packages/server/src/__tests__/middleware/validate.test.ts create mode 100644 packages/server/src/__tests__/middlewares.test.ts create mode 100644 packages/server/src/__tests__/notification/notification.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63bdb1c..44094b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,8 @@ jobs: run: node common/scripts/install-run-rush.js install - name: Rush validate run: node common/scripts/install-run-rush.js validate --verbose - # - name: Rush test - # run: node common/scripts/install-run-rush.js test --verbose + - name: Rush test + run: node common/scripts/install-run-rush.js test --verbose - name: Publish packages if: startsWith(github.ref, 'refs/tags/v0.7.') || startsWith(github.ref, 'refs/tags/s0.7.') env: diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 4085fd6..a77fbbd 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -217,8 +217,8 @@ importers: specifier: workspace:^0.7.4 version: link:../types '@hcengineering/core': - specifier: ^0.7.7 - version: 0.7.7 + specifier: ^0.7.8 + version: 0.7.8 snappyjs: specifier: ^0.7.0 version: 0.7.0 @@ -275,8 +275,8 @@ importers: specifier: workspace:^0.7.4 version: link:../types '@hcengineering/core': - specifier: ^0.7.7 - version: 0.7.7 + specifier: ^0.7.8 + version: 0.7.8 devDependencies: '@hcengineering/platform-rig': specifier: ^0.7.19 @@ -339,8 +339,8 @@ importers: specifier: workspace:^0.7.4 version: link:../types '@hcengineering/core': - specifier: ^0.7.7 - version: 0.7.7 + specifier: ^0.7.8 + version: 0.7.8 '@hcengineering/hulylake-client': specifier: ^0.7.6 version: 0.7.6 @@ -408,6 +408,9 @@ importers: prettier: specifier: ^3.1.0 version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10))(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -470,8 +473,8 @@ importers: ../../packages/types: dependencies: '@hcengineering/core': - specifier: ^0.7.7 - version: 0.7.7 + specifier: ^0.7.8 + version: 0.7.8 devDependencies: '@hcengineering/platform-rig': specifier: ^0.7.19 @@ -1062,8 +1065,8 @@ packages: '@hcengineering/analytics@0.7.5': resolution: {integrity: sha512-pc76hJxJWh3/9//P/+l8WyowJ/+tpmicrTXwG5c5TBPXAEtqvat/vZF8WOgCo0/0tajISChJ4l1O2GJuPzljhA==} - '@hcengineering/core@0.7.7': - resolution: {integrity: sha512-SgCZ87CHeOuUjxkKOYQlW3fKf6WSTgGjJi9vHWt2ceqhqb9eFc8x3RtDwKsHo3MW6+dzg/f76SMTwYkV5qyJSA==} + '@hcengineering/core@0.7.8': + resolution: {integrity: sha512-Ed4JktNRR3TkLwchPUUpD19t+zGX13rARuT21OXaRmoQWpV7dm4bK5YB9kxa+cgol0EJDg53+gEAMAyt+BgYWQ==} '@hcengineering/hulylake-client@0.7.6': resolution: {integrity: sha512-UwWerlpMVSJgkb6Z8qBnbX4Eq5HoCEAlLtHYKV0SIyBnuubfhxfEN2NMK86EGWwmOBbf5urozdVrQWRfZTvyLQ==} @@ -1475,6 +1478,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} @@ -2043,6 +2050,11 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -2482,6 +2494,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -2495,6 +2510,9 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -2559,6 +2577,9 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -2842,6 +2863,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3061,6 +3087,33 @@ packages: peerDependencies: typescript: '>=4.2.0' + ts-jest@29.4.5: + resolution: {integrity: sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -3083,6 +3136,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -3107,6 +3164,11 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -3166,6 +3228,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3603,14 +3668,14 @@ snapshots: '@hcengineering/account-client@0.7.5': dependencies: - '@hcengineering/core': 0.7.7 + '@hcengineering/core': 0.7.8 '@hcengineering/platform': 0.7.5 '@hcengineering/analytics@0.7.5': dependencies: '@hcengineering/platform': 0.7.5 - '@hcengineering/core@0.7.7': + '@hcengineering/core@0.7.8': dependencies: '@hcengineering/analytics': 0.7.5 '@hcengineering/measurements': 0.7.13 @@ -3619,7 +3684,7 @@ snapshots: '@hcengineering/hulylake-client@0.7.6': dependencies: - '@hcengineering/core': 0.7.7 + '@hcengineering/core': 0.7.8 '@hcengineering/retry': 0.7.5 '@hcengineering/measurements@0.7.13': {} @@ -3665,14 +3730,14 @@ snapshots: '@hcengineering/server-token@0.7.5': dependencies: - '@hcengineering/core': 0.7.7 + '@hcengineering/core': 0.7.8 '@hcengineering/platform': 0.7.5 jwt-simple: 0.5.6 uuid: 8.3.2 '@hcengineering/text-core@0.7.5': dependencies: - '@hcengineering/core': 0.7.7 + '@hcengineering/core': 0.7.8 fast-equals: 5.3.2 hash-it: 6.0.0 @@ -4249,6 +4314,10 @@ snapshots: node-releases: 2.0.23 update-browserslist-db: 1.1.3(browserslist@4.26.3) + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + bser@2.1.1: dependencies: node-int64: 0.4.0 @@ -4997,6 +5066,15 @@ snapshots: graphemer@1.4.0: {} + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -5613,6 +5691,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.memoize@4.1.2: {} + lodash.merge@4.6.2: {} lru-cache@5.1.1: @@ -5627,6 +5707,8 @@ snapshots: dependencies: semver: 7.7.2 + make-error@1.3.6: {} + makeerror@1.0.12: dependencies: tmpl: 1.0.5 @@ -5681,6 +5763,8 @@ snapshots: natural-compare@1.4.0: {} + neo-async@2.6.2: {} + node-int64@0.4.0: {} node-releases@2.0.23: {} @@ -5952,6 +6036,8 @@ snapshots: semver@7.7.2: {} + semver@7.7.3: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -6184,6 +6270,27 @@ snapshots: dependencies: typescript: 5.9.3 + ts-jest@29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 29.7.0(@types/node@22.18.10) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.3 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.28.4 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.28.4) + esbuild: 0.25.10 + jest-util: 29.7.0 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -6203,6 +6310,8 @@ snapshots: type-fest@0.21.3: {} + type-fest@4.41.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -6240,6 +6349,9 @@ snapshots: uc.micro@2.1.0: {} + uglify-js@3.19.3: + optional: true + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -6322,6 +6434,8 @@ snapshots: word-wrap@1.2.5: {} + wordwrap@1.0.0: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/packages/rest-client/package.json b/packages/rest-client/package.json index 2e92009..c8b8428 100644 --- a/packages/rest-client/package.json +++ b/packages/rest-client/package.json @@ -40,7 +40,7 @@ "@hcengineering/communication-types": "workspace:^0.7.4", "@hcengineering/communication-sdk-types": "workspace:^0.7.4", "@hcengineering/communication-shared": "workspace:^0.7.4", - "@hcengineering/core": "^0.7.7", + "@hcengineering/core": "^0.7.8", "snappyjs": "^0.7.0" }, "repository": { diff --git a/packages/sdk-types/package.json b/packages/sdk-types/package.json index aa9ed46..6884037 100644 --- a/packages/sdk-types/package.json +++ b/packages/sdk-types/package.json @@ -36,7 +36,7 @@ "typescript": "^5.9.3" }, "dependencies": { - "@hcengineering/core": "^0.7.7", + "@hcengineering/core": "^0.7.8", "@hcengineering/communication-types": "workspace:^0.7.4" }, "repository": { diff --git a/packages/server/jest.config.js b/packages/server/jest.config.js new file mode 100644 index 0000000..c137fcf --- /dev/null +++ b/packages/server/jest.config.js @@ -0,0 +1,16 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ['./src'], + collectCoverage: true, + coverageReporters: ['text-summary', 'html', 'lcov'], + coverageDirectory: 'coverage', + moduleNameMapper: { + '^franc-min$': '/src/__mocks__/franc-min.ts' + }, + transformIgnorePatterns: [ + 'node_modules/(?!(franc-min|trigram-utils)/)' + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] +} diff --git a/packages/server/package.json b/packages/server/package.json index a7520d8..f890404 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,7 +17,9 @@ "lint": "eslint \"src/**/*.ts\"", "lint:fix": "eslint --fix \"src/**/*.ts\"", "format": "prettier --write src/**/*.ts && eslint --fix \"src/**/*.ts\"", - "clean": "rm -rf lib && rm -rf types rm -rf node_modules" + "clean": "rm -rf lib && rm -rf types rm -rf node_modules", + "test": "jest --passWithNoTests --silent --forceExit", + "_phase:test": "jest --passWithNoTests --silent --forceExit" }, "devDependencies": { "@hcengineering/platform-rig": "^0.7.19", @@ -35,6 +37,7 @@ "eslint-plugin-promise": "^6.1.1", "jest": "^29.7.0", "prettier": "^3.1.0", + "ts-jest": "^29.1.1", "typescript": "^5.9.3" }, "dependencies": { @@ -43,7 +46,7 @@ "@hcengineering/communication-sdk-types": "workspace:^0.7.4", "@hcengineering/communication-shared": "workspace:^0.7.4", "@hcengineering/communication-types": "workspace:^0.7.4", - "@hcengineering/core": "^0.7.7", + "@hcengineering/core": "^0.7.8", "@hcengineering/server-token": "^0.7.5", "@hcengineering/text-core": "^0.7.5", "@hcengineering/text-markdown": "^0.7.5", diff --git a/packages/server/src/__mocks__/franc-min.ts b/packages/server/src/__mocks__/franc-min.ts new file mode 100644 index 0000000..1e43842 --- /dev/null +++ b/packages/server/src/__mocks__/franc-min.ts @@ -0,0 +1,3 @@ +// Mock for franc-min ESM module +export const franc = jest.fn().mockReturnValue('eng') +export const francAll = jest.fn().mockReturnValue([['eng', 1]]) diff --git a/packages/server/src/__mocks__/notification/notification.ts b/packages/server/src/__mocks__/notification/notification.ts new file mode 100644 index 0000000..7101e0d --- /dev/null +++ b/packages/server/src/__mocks__/notification/notification.ts @@ -0,0 +1,2 @@ +// Mock for notification/notification +export const notify = jest.fn().mockResolvedValue([]) diff --git a/packages/server/src/__mocks__/triggers/all.ts b/packages/server/src/__mocks__/triggers/all.ts new file mode 100644 index 0000000..faf1ab6 --- /dev/null +++ b/packages/server/src/__mocks__/triggers/all.ts @@ -0,0 +1,2 @@ +// Mock for triggers/all +export default [] diff --git a/packages/server/src/__tests__/blob.test.ts b/packages/server/src/__tests__/blob.test.ts new file mode 100644 index 0000000..892940b --- /dev/null +++ b/packages/server/src/__tests__/blob.test.ts @@ -0,0 +1,947 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, SortingOrder, WorkspaceUuid, PersonUuid } from '@hcengineering/core' +import { HulylakeWorkspaceClient, getWorkspaceClient } from '@hcengineering/hulylake-client' +import { + CardID, + BlobID, + MessageID, + MessagesGroup, + Message, + Markdown, + MessageExtra, + Attachment, + AttachmentID, + Thread, + CardType, + AttachmentUpdateData, + MessageType, + SocialID +} from '@hcengineering/communication-types' +import { Blob } from '../blob' +import { Metadata } from '../types' + +// Mock dependencies +jest.mock('@hcengineering/hulylake-client') +jest.mock('@hcengineering/server-token', () => ({ + generateToken: jest.fn(() => 'mock-token') +})) +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'mock-uuid') +})) + +describe('Blob', () => { + let blob: Blob + let mockClient: jest.Mocked + let mockCtx: jest.Mocked + let mockMetadata: Metadata + + const workspace = 'test-workspace' as WorkspaceUuid + const cardId = 'test-card-id' as CardID + const blobId = 'test-blob-id' as BlobID + const messageId = 'test-message-id' as MessageID + + beforeEach(() => { + // Setup mock context + mockCtx = { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn() + } as any + + // Setup mock metadata + mockMetadata = { + accountsUrl: 'http://accounts.test', + hulylakeUrl: 'http://hulylake.test', + secret: 'test-secret', + messagesPerBlob: 100 + } + + // Setup mock client + mockClient = { + getJson: jest.fn(), + putJson: jest.fn(), + patchJson: jest.fn() + } as any + + // Mock getWorkspaceClient to return our mock client + ;(getWorkspaceClient as jest.Mock).mockReturnValue(mockClient) + + blob = new Blob(mockCtx, workspace, mockMetadata) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('findMessagesGroups', () => { + beforeEach(() => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-02T00:00:00.000Z', + count: 10 + }, + 'blob-2': { + cardId, + blobId: 'blob-2' as BlobID, + fromDate: '2025-01-03T00:00:00.000Z', + toDate: '2025-01-04T00:00:00.000Z', + count: 20 + }, + 'blob-3': { + cardId, + blobId: 'blob-3' as BlobID, + fromDate: '2025-01-05T00:00:00.000Z', + toDate: '2025-01-06T00:00:00.000Z', + count: 30 + } + } + } as any) + }) + + it('should return all groups when no filters are provided', async () => { + const result = await blob.findMessagesGroups({ cardId }) + + expect(result).toHaveLength(3) + expect(result[0].blobId).toBe('blob-1') + expect(result[1].blobId).toBe('blob-2') + expect(result[2].blobId).toBe('blob-3') + }) + + it('should sort groups in ascending order', async () => { + const result = await blob.findMessagesGroups({ + cardId, + order: SortingOrder.Ascending + }) + + expect(result[0].fromDate.getTime()).toBeLessThan(result[1].fromDate.getTime()) + expect(result[1].fromDate.getTime()).toBeLessThan(result[2].fromDate.getTime()) + }) + + it('should sort groups in descending order', async () => { + const result = await blob.findMessagesGroups({ + cardId, + order: SortingOrder.Descending + }) + + expect(result[0].fromDate.getTime()).toBeGreaterThan(result[1].fromDate.getTime()) + expect(result[1].fromDate.getTime()).toBeGreaterThan(result[2].fromDate.getTime()) + }) + + it('should filter groups by blobId', async () => { + const result = await blob.findMessagesGroups({ + cardId, + blobId: 'blob-2' as BlobID + }) + + expect(result).toHaveLength(1) + expect(result[0].blobId).toBe('blob-2') + }) + + it('should filter groups by fromDate', async () => { + const result = await blob.findMessagesGroups({ + cardId, + fromDate: new Date('2025-01-03') + }) + + expect(result).toHaveLength(1) + expect(result[0].blobId).toBe('blob-2') + }) + + it('should filter groups by toDate', async () => { + const result = await blob.findMessagesGroups({ + cardId, + toDate: new Date('2025-01-04') + }) + + expect(result).toHaveLength(1) + expect(result[0].blobId).toBe('blob-2') + }) + + it('should limit the number of returned groups', async () => { + const result = await blob.findMessagesGroups({ + cardId, + limit: 2 + }) + + expect(result).toHaveLength(2) + }) + + it('should create groups blob when not found (404)', async () => { + mockClient.getJson.mockResolvedValue({ + status: 404, + body: null + } as any) + + const result = await blob.findMessagesGroups({ cardId }) + + expect(result).toHaveLength(0) + expect(mockClient.putJson).toHaveBeenCalledWith(`${cardId}/messages/groups`, {}, undefined, expect.any(Object)) + }) + }) + + describe('getMessageGroupByDate', () => { + beforeEach(() => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-10T00:00:00.000Z', + count: 10 + } + } + } as any) + }) + + it('should return matching group when date is within range', async () => { + const result = await blob.getMessageGroupByDate(cardId, new Date('2025-01-05'), false) + + expect(result).toBeDefined() + expect(result?.blobId).toBe('blob-1') + }) + + it('should return last group if date is after and group is not full', async () => { + const result = await blob.getMessageGroupByDate(cardId, new Date('2025-01-15'), false) + + expect(result).toBeDefined() + expect(result?.blobId).toBe('blob-1') + }) + + it('should return undefined when no match and create is false', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-10T00:00:00.000Z', + count: 100 // Full group + } + } + } as any) + + const result = await blob.getMessageGroupByDate(cardId, new Date('2025-01-15'), false) + + expect(result).toBeUndefined() + }) + + it('should create new group when no match and create is true', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: {} + } as any) + + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + mockClient.putJson.mockResolvedValue({ status: 200 } as any) + + const result = await blob.getMessageGroupByDate(cardId, new Date('2025-01-15'), true) + + expect(result).toBeDefined() + expect(mockClient.patchJson).toHaveBeenCalled() + expect(mockClient.putJson).toHaveBeenCalled() + }) + }) + + describe('insertMessage', () => { + const mockGroup: MessagesGroup = { + cardId, + blobId, + fromDate: new Date('2025-01-01'), + toDate: new Date('2025-01-10'), + count: 5 + } + + const mockMessage: Message = { + id: messageId, + created: new Date('2025-01-05'), + creator: 'user-1' as SocialID, + content: 'Test message' as Markdown, + cardId, + type: MessageType.Text, + reactions: {}, + attachments: [], + threads: [] + } + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + [blobId]: { + cardId, + blobId, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-10T00:00:00.000Z', + count: 5 + } + } + } as any) + }) + + it('should insert message with correct patches', async () => { + await blob.insertMessage(cardId, mockGroup, mockMessage) + + expect(mockClient.patchJson).toHaveBeenCalledWith( + `${cardId}/messages/${blobId}`, + expect.arrayContaining([ + expect.objectContaining({ + hop: 'add', + path: `/messages/${messageId}` + }) + ]), + undefined, + expect.any(Object) + ) + }) + + it('should update toDate when message is newer', async () => { + const newerMessage = { + ...mockMessage, + created: new Date('2025-01-15') + } + + await blob.insertMessage(cardId, mockGroup, newerMessage) + + const patches = mockClient.patchJson.mock.calls[0][1] + const toDatePatch = patches.find((p) => p.path === '/toDate') + expect(toDatePatch).toBeDefined() + }) + + it('should update fromDate when message is older', async () => { + const olderMessage = { + ...mockMessage, + created: new Date('2024-12-31') + } + + await blob.insertMessage(cardId, mockGroup, olderMessage) + + const patches = mockClient.patchJson.mock.calls[0][1] + const fromDatePatch = patches.find((p) => p.path === '/fromDate') + expect(fromDatePatch).toBeDefined() + }) + }) + + describe('updateMessage', () => { + const date = new Date('2025-01-05') + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should update message content', async () => { + await blob.updateMessage(cardId, blobId, messageId, { content: 'Updated content' as Markdown }, date) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches.some((p) => p.path === `/messages/${messageId}/content`)).toBe(true) + expect(patches.some((p) => p.path === `/messages/${messageId}/modified`)).toBe(true) + }) + + it('should update message extra', async () => { + const extra: MessageExtra = { key: 'value' } + await blob.updateMessage(cardId, blobId, messageId, { extra }, date) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches.some((p) => p.path === `/messages/${messageId}/extra`)).toBe(true) + }) + + it('should update message language without modifying modified field', async () => { + await blob.updateMessage(cardId, blobId, messageId, { language: 'en' }, date) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches.some((p) => p.path === `/messages/${messageId}/language`)).toBe(true) + expect(patches.some((p) => p.path === `/messages/${messageId}/modified`)).toBe(false) + }) + + it('should not call patchJson when no updates provided', async () => { + await blob.updateMessage(cardId, blobId, messageId, {}, date) + + expect(mockClient.patchJson).not.toHaveBeenCalled() + }) + }) + + describe('removeMessage', () => { + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + [blobId]: { + cardId, + blobId, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-10T00:00:00.000Z', + count: 5 + } + } + } as any) + }) + + it('should remove message with correct patch', async () => { + await blob.removeMessage(cardId, blobId, messageId) + + expect(mockClient.patchJson).toHaveBeenCalledWith( + `${cardId}/messages/${blobId}`, + expect.arrayContaining([ + expect.objectContaining({ + hop: 'remove', + path: `/messages/${messageId}`, + safe: true + }) + ]), + undefined, + expect.any(Object) + ) + }) + }) + + describe('addReaction', () => { + const person = 'user-1' as PersonUuid + const emoji = '👍' + const date = new Date('2025-01-05') + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should add reaction with correct patches', async () => { + await blob.addReaction(cardId, blobId, messageId, emoji, person, date) + + expect(mockClient.patchJson).toHaveBeenCalledWith( + `${cardId}/messages/${blobId}`, + expect.arrayContaining([ + expect.objectContaining({ + hop: 'add', + path: `/messages/${messageId}/reactions/${emoji}` + }), + expect.objectContaining({ + hop: 'add', + path: `/messages/${messageId}/reactions/${emoji}/${person}` + }) + ]), + undefined, + expect.any(Object) + ) + }) + }) + + describe('removeReaction', () => { + const person = 'user-1' as PersonUuid + const emoji = '👍' + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should remove reaction with correct patch', async () => { + await blob.removeReaction(cardId, blobId, messageId, emoji, person) + + expect(mockClient.patchJson).toHaveBeenCalledWith( + `${cardId}/messages/${blobId}`, + expect.arrayContaining([ + expect.objectContaining({ + hop: 'remove', + path: `/messages/${messageId}/reactions/${emoji}/${person}`, + safe: true + }) + ]), + undefined, + expect.any(Object) + ) + }) + }) + + describe('addAttachments', () => { + const attachments: Attachment[] = [ + { + id: 'att-1' as AttachmentID, + mimeType: 'text/plain', + params: { blobId: 'blob-1' as BlobID, fileName: 'file1.txt', size: 1024 }, + creator: 'user-1' as SocialID, + created: new Date('2025-01-05') + }, + { + id: 'att-2' as AttachmentID, + mimeType: 'text/plain', + params: { blobId: 'blob-2' as BlobID, fileName: 'file2.txt', size: 2048 }, + creator: 'user-1' as SocialID, + created: new Date('2025-01-05') + } + ] + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should add multiple attachments', async () => { + await blob.addAttachments(cardId, blobId, messageId, attachments) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches).toHaveLength(2) + expect(patches[0].path).toBe(`/messages/${messageId}/attachments/att-1`) + expect(patches[1].path).toBe(`/messages/${messageId}/attachments/att-2`) + }) + }) + + describe('removeAttachments', () => { + const attachmentIds: AttachmentID[] = ['att-1' as AttachmentID, 'att-2' as AttachmentID] + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should remove multiple attachments', async () => { + await blob.removeAttachments(cardId, blobId, messageId, attachmentIds) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches).toHaveLength(2) + expect(patches[0].path).toBe(`/messages/${messageId}/attachments/att-1`) + expect(patches[1].path).toBe(`/messages/${messageId}/attachments/att-2`) + }) + }) + + describe('setAttachments', () => { + const attachments: Attachment[] = [ + { + id: 'att-1' as AttachmentID, + mimeType: 'text/plain', + params: { blobId: 'blob-1' as BlobID, fileName: 'file1.txt', size: 1024 }, + creator: 'user-1' as SocialID, + created: new Date('2025-01-05') + } + ] + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should replace all attachments', async () => { + await blob.setAttachments(cardId, blobId, messageId, attachments) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches[0].path).toBe(`/messages/${messageId}/attachments`) + }) + }) + + describe('updateAttachments', () => { + const updates: AttachmentUpdateData[] = [ + { + id: 'att-1' as AttachmentID, + params: { status: 'processed' } + } + ] + const date = new Date('2025-01-05') + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should update attachment parameters', async () => { + await blob.updateAttachments(cardId, blobId, messageId, updates, date) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches.some((p) => p.path.includes('/params/status'))).toBe(true) + expect(patches.some((p) => p.path.includes('/modified'))).toBe(true) + }) + + it('should skip updates with empty params', async () => { + const emptyUpdates: AttachmentUpdateData[] = [ + { + id: 'att-1' as AttachmentID, + params: {} + } + ] + + await blob.updateAttachments(cardId, blobId, messageId, emptyUpdates, date) + + expect(mockClient.patchJson).toHaveBeenCalledWith(expect.any(String), [], undefined, expect.any(Object)) + }) + }) + + describe('attachThread', () => { + const thread: Thread = { + cardId, + messageId, + threadId: 'thread-1' as CardID, + threadType: 'discussion' as CardType, + repliesCount: 0, + lastReplyDate: undefined, + repliedPersons: {} + } + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should attach thread to message', async () => { + await blob.attachThread(cardId, blobId, messageId, thread) + + expect(mockClient.patchJson).toHaveBeenCalledWith( + `${cardId}/messages/${blobId}`, + expect.arrayContaining([ + expect.objectContaining({ + op: 'add', + path: `/messages/${messageId}/threads/${thread.threadId}` + }) + ]), + undefined, + expect.any(Object) + ) + }) + }) + + describe('updateThread', () => { + const threadId = 'thread-1' as CardID + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should update thread type', async () => { + await blob.updateThread(cardId, blobId, messageId, threadId, { + threadType: 'task' as CardType + }) + + expect(mockClient.patchJson).toHaveBeenCalledWith( + `${cardId}/messages/${blobId}`, + expect.arrayContaining([ + expect.objectContaining({ + op: 'add', + path: `/messages/${messageId}/threads/${threadId}/threadType`, + value: 'task' + }) + ]), + undefined, + expect.any(Object) + ) + }) + }) + + describe('addThreadReply', () => { + const threadId = 'thread-1' as CardID + const person = 'user-1' as PersonUuid + const date = new Date('2025-01-05') + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should increment replies count and update last reply', async () => { + await blob.addThreadReply(cardId, blobId, messageId, threadId, person, date) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches.some((p) => p.path.includes('/repliesCount'))).toBe(true) + expect(patches.some((p) => p.path.includes('/lastReply'))).toBe(true) + expect(patches.some((p) => p.path.includes('/repliedPersons'))).toBe(true) + }) + }) + + describe('removeThreadReply', () => { + const threadId = 'thread-1' as CardID + const person = 'user-1' as PersonUuid + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should decrement replies count', async () => { + await blob.removeThreadReply(cardId, blobId, messageId, threadId, person) + + const patches = mockClient.patchJson.mock.calls[0][1] + const repliesCountPatch = patches.find((p) => p.path.includes('/repliesCount')) + expect(repliesCountPatch).toBeDefined() + expect((repliesCountPatch as any).value).toBe(-1) + }) + }) + + describe('removeThread', () => { + const threadId = 'thread-1' as CardID + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should remove thread from message', async () => { + await blob.removeThread(cardId, blobId, messageId, threadId) + + expect(mockClient.patchJson).toHaveBeenCalledWith( + `${cardId}/messages/${blobId}`, + expect.arrayContaining([ + expect.objectContaining({ + hop: 'remove', + path: `/messages/${messageId}/threads/${threadId}`, + safe: true + }) + ]), + undefined, + expect.any(Object) + ) + }) + }) + + describe('Message group selection logic', () => { + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + mockClient.putJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should insert message into group that matches date range', async () => { + // Setup: three groups with different date ranges + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-05T00:00:00.000Z', + count: 10 + }, + 'blob-2': { + cardId, + blobId: 'blob-2' as BlobID, + fromDate: '2025-01-06T00:00:00.000Z', + toDate: '2025-01-10T00:00:00.000Z', + count: 20 + }, + 'blob-3': { + cardId, + blobId: 'blob-3' as BlobID, + fromDate: '2025-01-11T00:00:00.000Z', + toDate: '2025-01-15T00:00:00.000Z', + count: 30 + } + } + } as any) + + // Message with date in the range of the second group + const messageDate = new Date('2025-01-08T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + expect(group).toBeDefined() + expect(group?.blobId).toBe('blob-2') + expect(group?.fromDate.getTime()).toBeLessThanOrEqual(messageDate.getTime()) + expect(group?.toDate.getTime()).toBeGreaterThanOrEqual(messageDate.getTime()) + }) + + it('should insert message into last group if date is after all groups and group is not full', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-05T00:00:00.000Z', + count: 50 // Not full (< 100) + } + } + } as any) + + // Message with date after the last group + const messageDate = new Date('2025-01-10T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + expect(group).toBeDefined() + expect(group?.blobId).toBe('blob-1') + expect(group?.count).toBeLessThan(mockMetadata.messagesPerBlob) + }) + + it('should insert message into first group if date is before all groups and group is not full', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-10T00:00:00.000Z', + toDate: '2025-01-15T00:00:00.000Z', + count: 50 // Not full + } + } + } as any) + + // Message with date before the first group + const messageDate = new Date('2025-01-05T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + expect(group).toBeDefined() + expect(group?.blobId).toBe('blob-1') + expect(group?.count).toBeLessThan(mockMetadata.messagesPerBlob) + }) + + it('should NOT use last group if it is full', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-05T00:00:00.000Z', + count: 100 // Full group + } + } + } as any) + + // Message with date after the last group + const messageDate = new Date('2025-01-10T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + // Should not find a group since the only group is full + expect(group).toBeUndefined() + }) + + it('should NOT use first group if it is full', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-10T00:00:00.000Z', + toDate: '2025-01-15T00:00:00.000Z', + count: 100 // Full group + } + } + } as any) + + // Message with date before the first group + const messageDate = new Date('2025-01-05T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + // Should not find a group since the only group is full + expect(group).toBeUndefined() + }) + + it('should create new group when no suitable group exists and create=true', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-05T00:00:00.000Z', + count: 100 // Full group + } + } + } as any) + + const messageDate = new Date('2025-01-10T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, true) + + expect(group).toBeDefined() + expect(mockClient.patchJson).toHaveBeenCalled() + expect(mockClient.putJson).toHaveBeenCalled() + }) + + it('should select correct group among multiple groups', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-03T00:00:00.000Z', + count: 100 // Full + }, + 'blob-2': { + cardId, + blobId: 'blob-2' as BlobID, + fromDate: '2025-01-04T00:00:00.000Z', + toDate: '2025-01-06T00:00:00.000Z', + count: 50 // Not full + }, + 'blob-3': { + cardId, + blobId: 'blob-3' as BlobID, + fromDate: '2025-01-07T00:00:00.000Z', + toDate: '2025-01-09T00:00:00.000Z', + count: 100 // Full + } + } + } as any) + + // Message should go into blob-2 since its date is in range + const messageDate = new Date('2025-01-05T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + expect(group).toBeDefined() + expect(group?.blobId).toBe('blob-2') + }) + + it('should use last non-full group for date after all groups', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-03T00:00:00.000Z', + count: 100 // Full + }, + 'blob-2': { + cardId, + blobId: 'blob-2' as BlobID, + fromDate: '2025-01-04T00:00:00.000Z', + toDate: '2025-01-06T00:00:00.000Z', + count: 50 // Not full - last group + } + } + } as any) + + // Message with date after all groups + const messageDate = new Date('2025-01-10T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + expect(group).toBeDefined() + expect(group?.blobId).toBe('blob-2') + expect(group?.count).toBeLessThan(mockMetadata.messagesPerBlob) + }) + + it('should handle exact boundary dates correctly', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-05T23:59:59.999Z', + count: 50 + } + } + } as any) + + // Message exactly at the toDate boundary + const messageDate = new Date('2025-01-05T23:59:59.999Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + expect(group).toBeDefined() + expect(group?.blobId).toBe('blob-1') + }) + }) +}) diff --git a/packages/server/src/__tests__/client.test.ts b/packages/server/src/__tests__/client.test.ts new file mode 100644 index 0000000..5e75e4f --- /dev/null +++ b/packages/server/src/__tests__/client.test.ts @@ -0,0 +1,458 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, WorkspaceUuid, PersonUuid, Account } from '@hcengineering/core' +import { HulylakeWorkspaceClient, getWorkspaceClient } from '@hcengineering/hulylake-client' +import { getClient as getAccountClient } from '@hcengineering/account-client' +import { loadMessages } from '@hcengineering/communication-shared' +import { + CardID, + MessageID, + MessageMeta, + Message, + SocialID, + FindMessagesOptions, + BlobID +} from '@hcengineering/communication-types' +import { DbAdapter } from '@hcengineering/communication-sdk-types' +import { LowLevelClient } from '../client' +import { Blob } from '../blob' +import { Metadata } from '../types' + +// Mock dependencies +jest.mock('@hcengineering/hulylake-client') +jest.mock('@hcengineering/account-client') +jest.mock('@hcengineering/communication-shared') +jest.mock('@hcengineering/server-token', () => ({ + generateToken: jest.fn(() => 'mock-token') +})) + +describe('LowLevelClient', () => { + let client: LowLevelClient + let mockDbAdapter: jest.Mocked + let mockBlob: jest.Mocked + let mockMetadata: Metadata + let mockLakeClient: jest.Mocked + let mockAccountClient: any + let mockCtx: jest.Mocked + let mockAccount: Account + + const workspace = 'test-workspace' as WorkspaceUuid + const cardId = 'test-card-id' as CardID + const messageId = 'test-message-id' as MessageID + const blobId = 'test-blob-id' as BlobID + const socialId = 'social-id-123' as SocialID + const personUuid = 'person-uuid-123' as PersonUuid + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks() + + // Mock DbAdapter + mockDbAdapter = { + findMessagesMeta: jest.fn(), + removeMessageMeta: jest.fn() + } as any + + // Mock Blob + mockBlob = {} as any + + // Mock Metadata + mockMetadata = { + accountsUrl: 'http://accounts-url', + hulylakeUrl: 'http://hulylake-url', + secret: 'test-secret', + messagesPerBlob: 100 + } + + // Mock HulylakeWorkspaceClient + mockLakeClient = { + find: jest.fn(), + update: jest.fn() + } as any + + // Mock getWorkspaceClient + ;(getWorkspaceClient as jest.Mock).mockReturnValue(mockLakeClient) + + // Mock AccountClient + mockAccountClient = { + findPersonBySocialId: jest.fn() + } + ;(getAccountClient as jest.Mock).mockReturnValue(mockAccountClient) + + // Mock loadMessages + ;(loadMessages as jest.Mock).mockResolvedValue([]) + + // Mock MeasureContext + mockCtx = { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn() + } as any + + // Mock Account + mockAccount = { + uuid: personUuid, + socialIds: [] + } as any + + // Create client instance + client = new LowLevelClient(mockDbAdapter, mockBlob, mockMetadata, workspace) + }) + + describe('constructor', () => { + it('should initialize LowLevelClient with correct parameters', () => { + expect(client.db).toBe(mockDbAdapter) + expect(client.blob).toBe(mockBlob) + expect(getWorkspaceClient).toHaveBeenCalledWith(mockMetadata.hulylakeUrl, workspace, 'mock-token') + }) + }) + + describe('findMessage', () => { + it('should return undefined when message meta is not found', async () => { + mockDbAdapter.findMessagesMeta.mockResolvedValue([]) + + const result = await client.findMessage(cardId, messageId) + + expect(result).toBeUndefined() + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledWith({ + cardId, + id: messageId + }) + expect(loadMessages).not.toHaveBeenCalled() + }) + + it('should return message when meta is found', async () => { + const mockMeta: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + const mockMessage: Message = { + cardId, + id: messageId, + createdOn: Date.now(), + createdBy: personUuid + } as any + + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + ;(loadMessages as jest.Mock).mockResolvedValue([mockMessage]) + + const result = await client.findMessage(cardId, messageId) + + expect(result).toEqual(mockMessage) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledWith({ + cardId, + id: messageId + }) + expect(loadMessages).toHaveBeenCalledWith(mockLakeClient, blobId, { cardId, id: messageId }, undefined) + }) + + it('should pass options to loadMessages', async () => { + const mockMeta: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + const options: FindMessagesOptions = { + attachments: true + } + + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + ;(loadMessages as jest.Mock).mockResolvedValue([]) + + await client.findMessage(cardId, messageId, options) + + expect(loadMessages).toHaveBeenCalledWith(mockLakeClient, blobId, { cardId, id: messageId }, options) + }) + + it('should use cached message meta on subsequent calls', async () => { + const mockMeta: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + ;(loadMessages as jest.Mock).mockResolvedValue([]) + + // First call + await client.findMessage(cardId, messageId) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(1) + + // Second call - should use cache + await client.findMessage(cardId, messageId) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(1) + }) + }) + + describe('findPersonUuid', () => { + it('should return account uuid if socialId is in account socialIds', async () => { + mockAccount.socialIds = [socialId] + + const result = await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(result).toBe(personUuid) + expect(mockAccountClient.findPersonBySocialId).not.toHaveBeenCalled() + }) + + it('should return cached person uuid if available', async () => { + // First call to populate cache + mockAccountClient.findPersonBySocialId.mockResolvedValue(personUuid) + + await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(mockAccountClient.findPersonBySocialId).toHaveBeenCalledTimes(1) + + // Second call - should use cache + const result = await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(result).toBe(personUuid) + expect(mockAccountClient.findPersonBySocialId).toHaveBeenCalledTimes(1) + }) + + it('should return undefined if accountsUrl is empty', async () => { + const clientWithoutUrl = new LowLevelClient( + mockDbAdapter, + mockBlob, + { ...mockMetadata, accountsUrl: '' }, + workspace + ) + + const result = await clientWithoutUrl.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(result).toBeUndefined() + expect(mockAccountClient.findPersonBySocialId).not.toHaveBeenCalled() + }) + + it('should fetch person uuid from account client', async () => { + mockAccountClient.findPersonBySocialId.mockResolvedValue(personUuid) + + const result = await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(result).toBe(personUuid) + expect(getAccountClient).toHaveBeenCalledWith(mockMetadata.accountsUrl, 'mock-token') + expect(mockAccountClient.findPersonBySocialId).toHaveBeenCalledWith(socialId, false) + }) + + it('should pass requireAccount parameter to account client', async () => { + mockAccountClient.findPersonBySocialId.mockResolvedValue(personUuid) + + await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId, true) + + expect(mockAccountClient.findPersonBySocialId).toHaveBeenCalledWith(socialId, true) + }) + + it('should cache person uuid when found', async () => { + mockAccountClient.findPersonBySocialId.mockResolvedValue(personUuid) + + await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + // Second call should use cache + await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(mockAccountClient.findPersonBySocialId).toHaveBeenCalledTimes(1) + }) + + it('should not cache when person uuid is undefined', async () => { + mockAccountClient.findPersonBySocialId.mockResolvedValue(undefined) + + await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + // Second call should try again + await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(mockAccountClient.findPersonBySocialId).toHaveBeenCalledTimes(2) + }) + + it('should handle errors and log warning', async () => { + const error = new Error('Network error') + mockAccountClient.findPersonBySocialId.mockRejectedValue(error) + + const result = await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(result).toBeUndefined() + expect(mockCtx.warn).toHaveBeenCalledWith('Cannot find person uuid', { + socialString: socialId, + err: error + }) + }) + }) + + describe('getMessageMeta', () => { + it('should return cached message meta if available', async () => { + const mockMeta: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + + // First call + const result1 = await client.getMessageMeta(cardId, messageId) + expect(result1).toEqual(mockMeta) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(1) + + // Second call - should use cache + const result2 = await client.getMessageMeta(cardId, messageId) + expect(result2).toEqual(mockMeta) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(1) + }) + + it('should fetch message meta from db if not cached', async () => { + const mockMeta: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + + const result = await client.getMessageMeta(cardId, messageId) + + expect(result).toEqual(mockMeta) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledWith({ + cardId, + id: messageId + }) + }) + + it('should return undefined if message meta not found', async () => { + mockDbAdapter.findMessagesMeta.mockResolvedValue([]) + + const result = await client.getMessageMeta(cardId, messageId) + + expect(result).toBeUndefined() + }) + + it('should cache message meta after fetching', async () => { + const mockMeta: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + + await client.getMessageMeta(cardId, messageId) + await client.getMessageMeta(cardId, messageId) + + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(1) + }) + + it('should handle different cardId and messageId combinations separately', async () => { + const cardId2 = 'card-2' as CardID + const messageId2 = 'message-2' as MessageID + + const mockMeta1: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + const mockMeta2: MessageMeta = { + cardId: cardId2, + id: messageId2, + blobId: 'blob-2' as BlobID, + createdOn: Date.now() + } as any + + mockDbAdapter.findMessagesMeta.mockResolvedValueOnce([mockMeta1]).mockResolvedValueOnce([mockMeta2]) + + const result1 = await client.getMessageMeta(cardId, messageId) + const result2 = await client.getMessageMeta(cardId2, messageId2) + + expect(result1).toEqual(mockMeta1) + expect(result2).toEqual(mockMeta2) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(2) + }) + }) + + describe('removeMessageMeta', () => { + it('should remove message meta from db and cache', async () => { + const mockMeta: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + // First, add to cache + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + await client.getMessageMeta(cardId, messageId) + + // Remove + await client.removeMessageMeta(cardId, messageId) + + expect(mockDbAdapter.removeMessageMeta).toHaveBeenCalledWith(cardId, messageId) + + // Verify cache is cleared by checking if db is called again + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + await client.getMessageMeta(cardId, messageId) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(2) + }) + + it('should call db removeMessageMeta even if not in cache', async () => { + await client.removeMessageMeta(cardId, messageId) + + expect(mockDbAdapter.removeMessageMeta).toHaveBeenCalledWith(cardId, messageId) + }) + + it('should only remove specific message from cache', async () => { + const cardId2 = 'card-2' as CardID + const messageId2 = 'message-2' as MessageID + + const mockMeta1: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + const mockMeta2: MessageMeta = { + cardId: cardId2, + id: messageId2, + blobId: 'blob-2' as BlobID, + createdOn: Date.now() + } as any + + // Add both to cache + mockDbAdapter.findMessagesMeta.mockResolvedValueOnce([mockMeta1]).mockResolvedValueOnce([mockMeta2]) + + await client.getMessageMeta(cardId, messageId) + await client.getMessageMeta(cardId2, messageId2) + + // Remove first one + await client.removeMessageMeta(cardId, messageId) + + // First should be removed from cache + mockDbAdapter.findMessagesMeta.mockResolvedValueOnce([mockMeta1]) + await client.getMessageMeta(cardId, messageId) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(3) + + // Second should still be cached + await client.getMessageMeta(cardId2, messageId2) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(3) + }) + }) +}) diff --git a/packages/server/src/__tests__/error.test.ts b/packages/server/src/__tests__/error.test.ts new file mode 100644 index 0000000..bba6553 --- /dev/null +++ b/packages/server/src/__tests__/error.test.ts @@ -0,0 +1,307 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ApiError } from '../error' + +describe('ApiError', () => { + describe('badRequest', () => { + it('should create an error with 400 status code', () => { + const message = 'Invalid input' + const error = ApiError.badRequest(message) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toBeInstanceOf(Error) + expect(error.code).toBe(400) + expect(error.message).toBe(`Bad Request: ${message}`) + }) + + it('should include the custom message in the error message', () => { + const customMessage = 'Missing required field: userId' + const error = ApiError.badRequest(customMessage) + + expect(error.message).toContain(customMessage) + expect(error.message).toBe(`Bad Request: ${customMessage}`) + }) + + it('should have correct prototype chain', () => { + const error = ApiError.badRequest('test') + + expect(Object.getPrototypeOf(error)).toBe(ApiError.prototype) + }) + }) + + describe('forbidden', () => { + it('should create an error with 403 status code', () => { + const message = 'Access denied' + const error = ApiError.forbidden(message) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toBeInstanceOf(Error) + expect(error.code).toBe(403) + expect(error.message).toBe(`Forbidden: ${message}`) + }) + + it('should include the custom message in the error message', () => { + const customMessage = 'Insufficient permissions' + const error = ApiError.forbidden(customMessage) + + expect(error.message).toContain(customMessage) + expect(error.message).toBe(`Forbidden: ${customMessage}`) + }) + + it('should have correct prototype chain', () => { + const error = ApiError.forbidden('test') + + expect(Object.getPrototypeOf(error)).toBe(ApiError.prototype) + }) + }) + + describe('notFound', () => { + it('should create an error with 404 status code', () => { + const message = 'Resource not found' + const error = ApiError.notFound(message) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toBeInstanceOf(Error) + expect(error.code).toBe(404) + expect(error.message).toBe(`Not Found: ${message}`) + }) + + it('should include the custom message in the error message', () => { + const customMessage = 'User with id 123 not found' + const error = ApiError.notFound(customMessage) + + expect(error.message).toContain(customMessage) + expect(error.message).toBe(`Not Found: ${customMessage}`) + }) + + it('should have correct prototype chain', () => { + const error = ApiError.notFound('test') + + expect(Object.getPrototypeOf(error)).toBe(ApiError.prototype) + }) + }) + + describe('toJSON', () => { + it('should return object with code and message for badRequest', () => { + const message = 'Invalid data' + const error = ApiError.badRequest(message) + const json = error.toJSON() + + expect(json).toEqual({ + code: 400, + message: `Bad Request: ${message}` + }) + }) + + it('should return object with code and message for forbidden', () => { + const message = 'Access denied' + const error = ApiError.forbidden(message) + const json = error.toJSON() + + expect(json).toEqual({ + code: 403, + message: `Forbidden: ${message}` + }) + }) + + it('should return object with code and message for notFound', () => { + const message = 'Resource missing' + const error = ApiError.notFound(message) + const json = error.toJSON() + + expect(json).toEqual({ + code: 404, + message: `Not Found: ${message}` + }) + }) + + it('should return a plain object', () => { + const error = ApiError.badRequest('test') + const json = error.toJSON() + + expect(typeof json).toBe('object') + expect(json.constructor).toBe(Object) + }) + }) + + describe('toString', () => { + it('should return JSON string representation for badRequest', () => { + const message = 'Invalid input' + const error = ApiError.badRequest(message) + const stringified = error.toString() + + expect(stringified).toBe( + JSON.stringify({ + code: 400, + message: `Bad Request: ${message}` + }) + ) + }) + + it('should return JSON string representation for forbidden', () => { + const message = 'No access' + const error = ApiError.forbidden(message) + const stringified = error.toString() + + expect(stringified).toBe( + JSON.stringify({ + code: 403, + message: `Forbidden: ${message}` + }) + ) + }) + + it('should return JSON string representation for notFound', () => { + const message = 'Page not found' + const error = ApiError.notFound(message) + const stringified = error.toString() + + expect(stringified).toBe( + JSON.stringify({ + code: 404, + message: `Not Found: ${message}` + }) + ) + }) + + it('should return valid JSON string', () => { + const error = ApiError.badRequest('test') + const stringified = error.toString() + + expect(() => JSON.parse(stringified)).not.toThrow() + const parsed = JSON.parse(stringified) + expect(parsed).toHaveProperty('code') + expect(parsed).toHaveProperty('message') + }) + }) + + describe('Error behavior', () => { + it('should be catchable as Error', () => { + try { + throw ApiError.badRequest('test error') + } catch (err) { + expect(err).toBeInstanceOf(Error) + expect(err).toBeInstanceOf(ApiError) + } + }) + + it('should preserve error message in stack trace', () => { + const message = 'Test error message' + const error = ApiError.badRequest(message) + + expect(error.stack).toBeDefined() + expect(error.stack).toContain('Bad Request: Test error message') + }) + + it('should have name property inherited from Error', () => { + const error = ApiError.badRequest('test') + + expect(error.name).toBe('Error') + }) + + it('should allow instanceof checks', () => { + const badRequestError = ApiError.badRequest('test') + const forbiddenError = ApiError.forbidden('test') + const notFoundError = ApiError.notFound('test') + + expect(badRequestError instanceof ApiError).toBe(true) + expect(forbiddenError instanceof ApiError).toBe(true) + expect(notFoundError instanceof ApiError).toBe(true) + expect(badRequestError instanceof Error).toBe(true) + expect(forbiddenError instanceof Error).toBe(true) + expect(notFoundError instanceof Error).toBe(true) + }) + }) + + describe('Edge cases', () => { + it('should handle empty string message', () => { + const error = ApiError.badRequest('') + + expect(error.message).toBe('Bad Request: ') + expect(error.code).toBe(400) + }) + + it('should handle special characters in message', () => { + const message = 'Error with "quotes" and \'apostrophes\' and \n newlines' + const error = ApiError.notFound(message) + + expect(error.message).toContain(message) + }) + + it('should handle unicode characters in message', () => { + const message = 'Ошибка с юникодом 🚀 и эмодзи' + const error = ApiError.forbidden(message) + + expect(error.message).toContain(message) + expect((error.toJSON() as any).message).toContain(message) + }) + + it('should handle very long messages', () => { + const longMessage = 'A'.repeat(1000) + const error = ApiError.badRequest(longMessage) + + expect(error.message).toContain(longMessage) + expect(error.message.length).toBeGreaterThan(1000) + }) + }) + + describe('Serialization', () => { + it('should be JSON.stringify compatible', () => { + const error = ApiError.badRequest('test') + + expect(() => JSON.stringify(error)).not.toThrow() + }) + + it('should produce same result from toJSON and JSON.stringify', () => { + const error = ApiError.forbidden('test') + const jsonObject = error.toJSON() + const stringified = JSON.stringify(jsonObject) + + expect(stringified).toBe(error.toString()) + }) + + it('should maintain data integrity through serialization', () => { + const message = 'Original message' + const error = ApiError.notFound(message) + const serialized = error.toString() + const deserialized = JSON.parse(serialized) + + expect(deserialized.code).toBe(404) + expect(deserialized.message).toBe(`Not Found: ${message}`) + }) + }) + + describe('Different error types comparison', () => { + it('should create distinct errors with different codes', () => { + const badRequest = ApiError.badRequest('test') + const forbidden = ApiError.forbidden('test') + const notFound = ApiError.notFound('test') + + expect(badRequest.code).not.toBe(forbidden.code) + expect(badRequest.code).not.toBe(notFound.code) + expect(forbidden.code).not.toBe(notFound.code) + }) + + it('should create distinct messages with different prefixes', () => { + const message = 'same message' + const badRequest = ApiError.badRequest(message) + const forbidden = ApiError.forbidden(message) + const notFound = ApiError.notFound(message) + + expect(badRequest.message).toContain('Bad Request') + expect(forbidden.message).toContain('Forbidden') + expect(notFound.message).toContain('Not Found') + }) + }) +}) diff --git a/packages/server/src/__tests__/index.test.ts b/packages/server/src/__tests__/index.test.ts new file mode 100644 index 0000000..aaf9b57 --- /dev/null +++ b/packages/server/src/__tests__/index.test.ts @@ -0,0 +1,483 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, WorkspaceUuid } from '@hcengineering/core' +import { + CardID, + Collaborator, + FindCollaboratorsParams, + FindLabelsParams, + FindMessagesGroupParams, + FindMessagesMetaParams, + FindNotificationContextParams, + FindNotificationsParams, + FindPeersParams, + Label, + MessageMeta, + MessagesGroup, + Notification, + NotificationContext, + Peer +} from '@hcengineering/communication-types' +import { Event, EventResult, SessionData } from '@hcengineering/communication-sdk-types' +import { createDbAdapter } from '@hcengineering/communication-cockroach' +import { Api } from '../index' +import { getMetadata } from '../metadata' +import { buildMiddlewares } from '../middlewares' +import { Blob } from '../blob' +import { LowLevelClient } from '../client' + +// Mock franc-min before any imports that use it +jest.mock('franc-min', () => ({ + franc: jest.fn(() => 'eng') +})) + +// Mock dependencies +jest.mock('@hcengineering/communication-cockroach') +jest.mock('../metadata') +jest.mock('../middlewares') +jest.mock('../blob') +jest.mock('../client') + +describe('Api', () => { + let mockCtx: jest.Mocked + let mockMiddlewares: any + let mockSession: SessionData + let mockDbAdapter: any + let mockBlob: jest.Mocked + let mockClient: jest.Mocked + let mockMetadata: any + + const workspace = 'test-workspace' as WorkspaceUuid + const dbUrl = 'postgresql://test-db' + const cardId = 'test-card-id' as CardID + + const createMockCallbacks = (): { registerAsyncRequest: jest.Mock, broadcast: jest.Mock, enqueue: jest.Mock } => ({ + registerAsyncRequest: jest.fn(), + broadcast: jest.fn(), + enqueue: jest.fn() + }) + + beforeEach(() => { + jest.clearAllMocks() + + // Mock MeasureContext + mockCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any + + // Mock SessionData + mockSession = { + sessionId: 'session-123', + account: { + uuid: 'account-uuid', + socialIds: [] + } + } as any + + // Mock metadata + mockMetadata = { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'test-secret', + messagesPerBlob: 200 + } + ;(getMetadata as jest.Mock).mockReturnValue(mockMetadata) + + // Mock DbAdapter + mockDbAdapter = { + findMessagesMeta: jest.fn(), + close: jest.fn() + } + ;(createDbAdapter as jest.Mock).mockResolvedValue(mockDbAdapter) + + // Mock Blob + mockBlob = { + createMessage: jest.fn() + } as any + ;(Blob as jest.Mock).mockImplementation(() => mockBlob) + + // Mock LowLevelClient + mockClient = { + db: mockDbAdapter, + blob: mockBlob + } as any + ;(LowLevelClient as jest.Mock).mockImplementation(() => mockClient) + + // Mock middlewares + mockMiddlewares = { + findMessagesMeta: jest.fn(), + findMessagesGroups: jest.fn(), + findNotificationContexts: jest.fn(), + findNotifications: jest.fn(), + findLabels: jest.fn(), + findCollaborators: jest.fn(), + findPeers: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + event: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } + ;(buildMiddlewares as jest.Mock).mockResolvedValue(mockMiddlewares) + }) + + describe('create', () => { + it('should create Api instance with all dependencies', async () => { + const callbacks = createMockCallbacks() + const api = await Api.create(mockCtx, workspace, dbUrl, callbacks) + + expect(api).toBeInstanceOf(Api) + expect(getMetadata).toHaveBeenCalled() + expect(createDbAdapter).toHaveBeenCalledWith(dbUrl, workspace, mockCtx, { withLogs: false }) + expect(Blob).toHaveBeenCalledWith(mockCtx, workspace, mockMetadata) + expect(LowLevelClient).toHaveBeenCalledWith(mockDbAdapter, mockBlob, mockMetadata, workspace) + expect(buildMiddlewares).toHaveBeenCalled() + }) + + it('should initialize middlewares with correct context', async () => { + const callbacks = createMockCallbacks() + await Api.create(mockCtx, workspace, dbUrl, callbacks) + + expect(buildMiddlewares).toHaveBeenCalledWith(mockCtx, workspace, mockMetadata, mockClient, callbacks) + }) + + it('should handle callback functions', async () => { + const callbacks = { + onCardUpdated: jest.fn(), + onMessageCreated: jest.fn(), + registerAsyncRequest: jest.fn(), + broadcast: jest.fn(), + enqueue: jest.fn() + } + + await Api.create(mockCtx, workspace, dbUrl, callbacks) + + expect(buildMiddlewares).toHaveBeenCalled() + }) + }) + + describe('findMessagesMeta', () => { + it('should delegate to middlewares.findMessagesMeta', async () => { + const params: FindMessagesMetaParams = { cardId } + const expectedResult: MessageMeta[] = [ + { cardId, id: 'msg-1' as any, blobId: 'blob-1' as any, createdOn: Date.now() } + ] as any + + mockMiddlewares.findMessagesMeta.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.findMessagesMeta(mockSession, params) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.findMessagesMeta).toHaveBeenCalledWith(mockSession, params) + }) + }) + + describe('findMessagesGroups', () => { + it('should delegate to middlewares.findMessagesGroups', async () => { + const params: FindMessagesGroupParams = { cardId } + const expectedResult: MessagesGroup[] = [{ cardId, blobId: 'blob-1' as any, count: 10 }] as any + + mockMiddlewares.findMessagesGroups.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.findMessagesGroups(mockSession, params) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.findMessagesGroups).toHaveBeenCalledWith(mockSession, params) + }) + }) + + describe('findNotificationContexts', () => { + it('should delegate to middlewares.findNotificationContexts without subscription', async () => { + const params: FindNotificationContextParams = { limit: 10 } + const expectedResult: NotificationContext[] = [{ id: 'ctx-1', cardId, unreadCount: 5 }] as any + + mockMiddlewares.findNotificationContexts.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.findNotificationContexts(mockSession, params) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.findNotificationContexts).toHaveBeenCalledWith(mockSession, params, undefined) + }) + + it('should pass subscription to middlewares.findNotificationContexts', async () => { + const params: FindNotificationContextParams = { limit: 10 } + const subscription = 'sub-123' + const expectedResult: NotificationContext[] = [] + + mockMiddlewares.findNotificationContexts.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + await api.findNotificationContexts(mockSession, params, subscription) + + expect(mockMiddlewares.findNotificationContexts).toHaveBeenCalledWith(mockSession, params, subscription) + }) + }) + + describe('findNotifications', () => { + it('should delegate to middlewares.findNotifications without subscription', async () => { + const params: FindNotificationsParams = { cardId } + const expectedResult: Notification[] = [{ id: 'notif-1', cardId, isRead: false }] as any + + mockMiddlewares.findNotifications.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.findNotifications(mockSession, params) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.findNotifications).toHaveBeenCalledWith(mockSession, params, undefined) + }) + + it('should pass subscription to middlewares.findNotifications', async () => { + const params: FindNotificationsParams = { cardId } + const subscription = 'sub-456' + const expectedResult: Notification[] = [] + + mockMiddlewares.findNotifications.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + await api.findNotifications(mockSession, params, subscription) + + expect(mockMiddlewares.findNotifications).toHaveBeenCalledWith(mockSession, params, subscription) + }) + }) + + describe('findLabels', () => { + it('should delegate to middlewares.findLabels', async () => { + const params: FindLabelsParams = { cardId } + const expectedResult: Label[] = [{ id: 'label-1', name: 'Bug', color: 'red' }] as any + + mockMiddlewares.findLabels.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.findLabels(mockSession, params) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.findLabels).toHaveBeenCalledWith(mockSession, params) + }) + }) + + describe('findCollaborators', () => { + it('should delegate to middlewares.findCollaborators', async () => { + const params: FindCollaboratorsParams = { cardId } + const expectedResult: Collaborator[] = [{ personUuid: 'person-1' as any, role: 'owner' }] as any + + mockMiddlewares.findCollaborators.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.findCollaborators(mockSession, params) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.findCollaborators).toHaveBeenCalledWith(mockSession, params) + }) + }) + + describe('findPeers', () => { + it('should delegate to middlewares.findPeers', async () => { + const params: FindPeersParams = { cardId } + const expectedResult: Peer[] = [{ personUuid: 'person-1' as any, name: 'John Doe' }] as any + + mockMiddlewares.findPeers.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.findPeers(mockSession, params) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.findPeers).toHaveBeenCalledWith(mockSession, params) + }) + }) + + describe('subscribeCard', () => { + it('should delegate to middlewares.subscribeCard', async () => { + const subscription = 'sub-789' + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + api.subscribeCard(mockSession, cardId, subscription) + + expect(mockMiddlewares.subscribeCard).toHaveBeenCalledWith(mockSession, cardId, subscription) + }) + + it('should not return a value', async () => { + const subscription = 'sub-789' + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + api.subscribeCard(mockSession, cardId, subscription) + + expect(mockMiddlewares.subscribeCard).toHaveBeenCalled() + }) + }) + + describe('unsubscribeCard', () => { + it('should delegate to middlewares.unsubscribeCard', async () => { + const subscription = 'sub-999' + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + api.unsubscribeCard(mockSession, cardId, subscription) + + expect(mockMiddlewares.unsubscribeCard).toHaveBeenCalledWith(mockSession, cardId, subscription) + }) + + it('should not return a value', async () => { + const subscription = 'sub-999' + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + api.unsubscribeCard(mockSession, cardId, subscription) + + expect(mockMiddlewares.unsubscribeCard).toHaveBeenCalled() + }) + }) + + describe('event', () => { + it('should delegate to middlewares.event', async () => { + const event: Event = { + type: 'message.create', + data: { cardId, content: 'Test message' } + } as any + + const expectedResult: EventResult = { + success: true + } as any + + mockMiddlewares.event.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.event(mockSession, event) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.event).toHaveBeenCalledWith(mockSession, event) + }) + + it('should handle different event types', async () => { + const events: Event[] = [ + { type: 'message.update', data: {} }, + { type: 'message.delete', data: {} }, + { type: 'notification.read', data: {} } + ] as any + + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + for (const event of events) { + mockMiddlewares.event.mockResolvedValue({ success: true }) + await api.event(mockSession, event) + expect(mockMiddlewares.event).toHaveBeenCalledWith(mockSession, event) + } + }) + }) + + describe('closeSession', () => { + it('should delegate to middlewares.closeSession', async () => { + const sessionId = 'session-to-close' + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + await api.closeSession(sessionId) + + expect(mockMiddlewares.closeSession).toHaveBeenCalledWith(sessionId) + }) + + it('should complete without errors', async () => { + const sessionId = 'session-123' + mockMiddlewares.closeSession.mockResolvedValue(undefined) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + await expect(api.closeSession(sessionId)).resolves.toBeUndefined() + }) + }) + + describe('close', () => { + it('should delegate to middlewares.close', async () => { + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + await api.close() + + expect(mockMiddlewares.close).toHaveBeenCalled() + }) + + it('should complete without errors', async () => { + mockMiddlewares.close.mockResolvedValue(undefined) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + await expect(api.close()).resolves.toBeUndefined() + }) + }) + + describe('Error handling', () => { + it('should propagate errors from middlewares', async () => { + const error = new Error('Middleware error') + mockMiddlewares.findMessagesMeta.mockRejectedValue(error) + + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + const params: FindMessagesMetaParams = { cardId } + + await expect(api.findMessagesMeta(mockSession, params)).rejects.toThrow(error) + }) + + it('should handle errors during creation', async () => { + const error = new Error('DB connection failed') + ;(createDbAdapter as jest.Mock).mockRejectedValue(error) + + await expect(Api.create(mockCtx, workspace, dbUrl, createMockCallbacks())).rejects.toThrow(error) + }) + }) + + describe('Integration scenarios', () => { + it('should handle multiple operations in sequence', async () => { + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + mockMiddlewares.findMessagesMeta.mockResolvedValue([]) + mockMiddlewares.findLabels.mockResolvedValue([]) + mockMiddlewares.findCollaborators.mockResolvedValue([]) + + await api.findMessagesMeta(mockSession, { cardId }) + await api.findLabels(mockSession, { cardId }) + await api.findCollaborators(mockSession, { cardId }) + + expect(mockMiddlewares.findMessagesMeta).toHaveBeenCalledTimes(1) + expect(mockMiddlewares.findLabels).toHaveBeenCalledTimes(1) + expect(mockMiddlewares.findCollaborators).toHaveBeenCalledTimes(1) + }) + + it('should handle subscription and unsubscription flow', async () => { + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + const subscription = 'sub-flow' + + api.subscribeCard(mockSession, cardId, subscription) + expect(mockMiddlewares.subscribeCard).toHaveBeenCalledWith(mockSession, cardId, subscription) + + api.unsubscribeCard(mockSession, cardId, subscription) + expect(mockMiddlewares.unsubscribeCard).toHaveBeenCalledWith(mockSession, cardId, subscription) + }) + + it('should handle session lifecycle', async () => { + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + const sessionId = 'lifecycle-session' + + // Perform operations + mockMiddlewares.findMessagesMeta.mockResolvedValue([]) + await api.findMessagesMeta(mockSession, { cardId }) + + // Close session + await api.closeSession(sessionId) + expect(mockMiddlewares.closeSession).toHaveBeenCalledWith(sessionId) + + // Close API + await api.close() + expect(mockMiddlewares.close).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/server/src/__tests__/messageId.test.ts b/packages/server/src/__tests__/messageId.test.ts new file mode 100644 index 0000000..dd622f0 --- /dev/null +++ b/packages/server/src/__tests__/messageId.test.ts @@ -0,0 +1,93 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { generateMessageId } from '../messageId' + +describe('messageId', () => { + describe('generateMessageId', () => { + it('should generate a valid MessageID', () => { + const id = generateMessageId() + + expect(id).toBeDefined() + expect(typeof id).toBe('string') + expect(id.length).toBeGreaterThan(0) + }) + + it('should generate numeric string IDs', () => { + const id = generateMessageId() + + expect(/^\d+$/.test(id)).toBe(true) + }) + + it('should generate unique IDs on consecutive calls', () => { + const id1 = generateMessageId() + const id2 = generateMessageId() + const id3 = generateMessageId() + + expect(id1).not.toBe(id2) + expect(id2).not.toBe(id3) + expect(id1).not.toBe(id3) + }) + + it('should generate monotonically increasing IDs', () => { + const id1 = BigInt(generateMessageId()) + const id2 = BigInt(generateMessageId()) + const id3 = BigInt(generateMessageId()) + + expect(id2).toBeGreaterThan(id1) + expect(id3).toBeGreaterThan(id2) + }) + + it('should generate IDs in sequence even when called rapidly', () => { + const ids = [] + const count = 100 + + for (let i = 0; i < count; i++) { + ids.push(generateMessageId()) + } + + const uniqueIds = new Set(ids) + expect(uniqueIds.size).toBe(count) + + for (let i = 1; i < ids.length; i++) { + expect(BigInt(ids[i])).toBeGreaterThan(BigInt(ids[i - 1])) + } + }) + + it('should generate IDs that can be parsed as BigInt', () => { + const id = generateMessageId() + + expect(() => BigInt(id)).not.toThrow() + const bigIntId = BigInt(id) + expect(bigIntId).toBeGreaterThan(0n) + }) + + it('should maintain order across multiple calls', () => { + const batch1 = Array.from({ length: 10 }, () => generateMessageId()) + const batch2 = Array.from({ length: 10 }, () => generateMessageId()) + + const lastFromBatch1 = BigInt(batch1[batch1.length - 1]) + const firstFromBatch2 = BigInt(batch2[0]) + + expect(firstFromBatch2).toBeGreaterThan(lastFromBatch1) + }) + + it('should not generate negative IDs', () => { + for (let i = 0; i < 20; i++) { + const id = generateMessageId() + expect(id.startsWith('-')).toBe(false) + expect(BigInt(id)).toBeGreaterThanOrEqual(0n) + } + }) + }) +}) diff --git a/packages/server/src/__tests__/metadata.test.ts b/packages/server/src/__tests__/metadata.test.ts new file mode 100644 index 0000000..5416694 --- /dev/null +++ b/packages/server/src/__tests__/metadata.test.ts @@ -0,0 +1,287 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { getMetadata } from '../metadata' +import { MessageID } from '@hcengineering/communication-types' +import { generateMessageId } from '../messageId' + +describe('metadata', () => { + let originalEnv: NodeJS.ProcessEnv + + beforeEach(() => { + // Save original environment + originalEnv = { ...process.env } + }) + + afterEach(() => { + // Restore original environment + process.env = originalEnv + }) + + describe('getMetadata', () => { + it('should return default values when environment variables are not set', () => { + delete process.env.ACCOUNTS_URL + delete process.env.SERVER_SECRET + delete process.env.HULYLAKE_URL + delete process.env.MESSAGES_PER_BLOB + + const metadata = getMetadata() + + expect(metadata).toEqual({ + accountsUrl: '', + secret: 'secret', + hulylakeUrl: 'http://huly.local:8096', + messagesPerBlob: 200 + }) + }) + + it('should use ACCOUNTS_URL from environment', () => { + process.env.ACCOUNTS_URL = 'http://custom-accounts-url' + + const metadata = getMetadata() + + expect(metadata.accountsUrl).toBe('http://custom-accounts-url') + }) + + it('should use SERVER_SECRET from environment', () => { + process.env.SERVER_SECRET = 'custom-secret-key' + + const metadata = getMetadata() + + expect(metadata.secret).toBe('custom-secret-key') + }) + + it('should use HULYLAKE_URL from environment', () => { + process.env.HULYLAKE_URL = 'http://custom-hulylake:9000' + + const metadata = getMetadata() + + expect(metadata.hulylakeUrl).toBe('http://custom-hulylake:9000') + }) + + it('should use MESSAGES_PER_BLOB from environment', () => { + process.env.MESSAGES_PER_BLOB = '500' + + const metadata = getMetadata() + + expect(metadata.messagesPerBlob).toBe(500) + }) + + it('should use all custom environment variables', () => { + process.env.ACCOUNTS_URL = 'http://accounts' + process.env.SERVER_SECRET = 'my-secret' + process.env.HULYLAKE_URL = 'http://hulylake:8080' + process.env.MESSAGES_PER_BLOB = '1000' + + const metadata = getMetadata() + + expect(metadata).toEqual({ + accountsUrl: 'http://accounts', + secret: 'my-secret', + hulylakeUrl: 'http://hulylake:8080', + messagesPerBlob: 1000 + }) + }) + + it('should handle empty string for ACCOUNTS_URL', () => { + process.env.ACCOUNTS_URL = '' + + const metadata = getMetadata() + + expect(metadata.accountsUrl).toBe('') + }) + + it('should convert MESSAGES_PER_BLOB to number', () => { + process.env.MESSAGES_PER_BLOB = '750' + + const metadata = getMetadata() + + expect(typeof metadata.messagesPerBlob).toBe('number') + expect(metadata.messagesPerBlob).toBe(750) + }) + + it('should handle invalid MESSAGES_PER_BLOB as NaN', () => { + process.env.MESSAGES_PER_BLOB = 'invalid' + + const metadata = getMetadata() + + expect(metadata.messagesPerBlob).toBeNaN() + }) + + it('should return a new object on each call', () => { + const metadata1 = getMetadata() + const metadata2 = getMetadata() + + expect(metadata1).not.toBe(metadata2) + expect(metadata1).toEqual(metadata2) + }) + + it('should have correct type structure', () => { + const metadata = getMetadata() + + expect(metadata).toHaveProperty('accountsUrl') + expect(metadata).toHaveProperty('secret') + expect(metadata).toHaveProperty('hulylakeUrl') + expect(metadata).toHaveProperty('messagesPerBlob') + + expect(typeof metadata.accountsUrl).toBe('string') + expect(typeof metadata.secret).toBe('string') + expect(typeof metadata.hulylakeUrl).toBe('string') + expect(typeof metadata.messagesPerBlob).toBe('number') + }) + + it('should handle zero MESSAGES_PER_BLOB', () => { + process.env.MESSAGES_PER_BLOB = '0' + + const metadata = getMetadata() + + expect(metadata.messagesPerBlob).toBe(0) + }) + + it('should handle negative MESSAGES_PER_BLOB', () => { + process.env.MESSAGES_PER_BLOB = '-100' + + const metadata = getMetadata() + + expect(metadata.messagesPerBlob).toBe(-100) + }) + + it('should handle floating point MESSAGES_PER_BLOB', () => { + process.env.MESSAGES_PER_BLOB = '123.45' + + const metadata = getMetadata() + + expect(metadata.messagesPerBlob).toBe(123.45) + }) + + it('should handle URLs with different protocols', () => { + process.env.ACCOUNTS_URL = 'https://secure-accounts.com' + process.env.HULYLAKE_URL = 'wss://hulylake.example.com' + + const metadata = getMetadata() + + expect(metadata.accountsUrl).toBe('https://secure-accounts.com') + expect(metadata.hulylakeUrl).toBe('wss://hulylake.example.com') + }) + + it('should handle URLs with ports', () => { + process.env.ACCOUNTS_URL = 'http://localhost:3000' + process.env.HULYLAKE_URL = 'http://localhost:8096' + + const metadata = getMetadata() + + expect(metadata.accountsUrl).toBe('http://localhost:3000') + expect(metadata.hulylakeUrl).toBe('http://localhost:8096') + }) + }) +}) + +describe('messageId', () => { + describe('generateMessageId', () => { + it('should generate a valid MessageID', () => { + const id = generateMessageId() + + expect(id).toBeDefined() + expect(typeof id).toBe('string') + expect(id.length).toBeGreaterThan(0) + }) + + it('should generate numeric string IDs', () => { + const id = generateMessageId() + + expect(/^\d+$/.test(id)).toBe(true) + }) + + it('should generate unique IDs on consecutive calls', () => { + const id1 = generateMessageId() + const id2 = generateMessageId() + const id3 = generateMessageId() + + expect(id1).not.toBe(id2) + expect(id2).not.toBe(id3) + expect(id1).not.toBe(id3) + }) + + it('should generate monotonically increasing IDs', () => { + const id1 = BigInt(generateMessageId()) + const id2 = BigInt(generateMessageId()) + const id3 = BigInt(generateMessageId()) + + expect(id2).toBeGreaterThan(id1) + expect(id3).toBeGreaterThan(id2) + }) + + it('should generate IDs in sequence even when called rapidly', () => { + const ids: MessageID[] = [] + const count = 100 + + for (let i = 0; i < count; i++) { + ids.push(generateMessageId()) + } + + // Check all IDs are unique + const uniqueIds = new Set(ids) + expect(uniqueIds.size).toBe(count) + + // Check IDs are monotonically increasing + for (let i = 1; i < ids.length; i++) { + expect(BigInt(ids[i])).toBeGreaterThan(BigInt(ids[i - 1])) + } + }) + + it('should handle concurrent generation correctly', () => { + const ids = new Set() + const iterations = 50 + + for (let i = 0; i < iterations; i++) { + ids.add(generateMessageId()) + } + + expect(ids.size).toBe(iterations) + }) + + it('should generate IDs that can be parsed as BigInt', () => { + const id = generateMessageId() + + expect(() => BigInt(id)).not.toThrow() + const bigIntId = BigInt(id) + expect(bigIntId).toBeGreaterThan(0n) + }) + + it('should maintain increasing order across multiple batches', () => { + const batch1 = Array.from({ length: 10 }, () => generateMessageId()) + const batch2 = Array.from({ length: 10 }, () => generateMessageId()) + + const lastFromBatch1 = BigInt(batch1[batch1.length - 1]) + const firstFromBatch2 = BigInt(batch2[0]) + + expect(firstFromBatch2).toBeGreaterThan(lastFromBatch1) + }) + + it('should generate IDs with reasonable length', () => { + const id = generateMessageId() + + // Should be a reasonable length (not too short, not too long) + expect(id.length).toBeGreaterThan(10) + expect(id.length).toBeLessThan(30) + }) + + it('should not generate negative IDs', () => { + for (let i = 0; i < 20; i++) { + const id = generateMessageId() + expect(id.startsWith('-')).toBe(false) + expect(BigInt(id)).toBeGreaterThanOrEqual(0n) + } + }) + }) +}) diff --git a/packages/server/src/__tests__/middleware/base.test.ts b/packages/server/src/__tests__/middleware/base.test.ts new file mode 100644 index 0000000..81d5291 --- /dev/null +++ b/packages/server/src/__tests__/middleware/base.test.ts @@ -0,0 +1,375 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type MeasureContext, type WorkspaceUuid } from '@hcengineering/core' +import { CreateMessageEvent, MessageEventType, type SessionData } from '@hcengineering/communication-sdk-types' +import { + type AccountUuid, + CardID, + type CardType, + type ContextID, + type Markdown, + MessageID, + MessageType, + type SocialID +} from '@hcengineering/communication-types' + +import { BaseMiddleware } from '../../middleware/base' +import { type Enriched, type Middleware, type MiddlewareContext } from '../../types' +import { type LowLevelClient } from '../../client' + +describe('BaseMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: jest.Mocked + let session: SessionData + let middleware: BaseMiddleware + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + const basicEvent = { + _id: 'event-123', + _eventExtra: {}, + socialId, + date: new Date() + } as const + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + mockClient = { + db: {}, + blob: {} + } as unknown as jest.Mocked + + mockNext = { + event: jest.fn().mockResolvedValue({ success: true }), + findNotificationContexts: jest.fn().mockResolvedValue([{ id: 'ctx-1' }]), + findNotifications: jest.fn().mockResolvedValue([{ id: 'notif-1' }]), + findLabels: jest.fn().mockResolvedValue([{ labelId: 'label-1' }]), + findCollaborators: jest.fn().mockResolvedValue([{ account: accountUuid }]), + findPeers: jest.fn().mockResolvedValue([{ cardId: 'card-1' }]), + findMessagesMeta: jest.fn().mockResolvedValue([{ messageId: 'msg-1' }]), + findMessagesGroups: jest.fn().mockResolvedValue([{ blobId: 'blob-1' }]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(), + closeSession: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123' + } as any as SessionData + + middleware = new BaseMiddleware(mockContext, mockNext) + }) + + describe('event', () => { + it('should delegate to next middleware', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + messageId: 'msg-123' as MessageID, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown, + socialId + } + + const result = await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + expect(result).toEqual({ success: true }) + }) + + it('should return empty object if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + messageId: 'msg-123' as MessageID, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown, + socialId + } + + const result = await middlewareWithoutNext.event(session, event, false) + + expect(result).toEqual({}) + }) + }) + + describe('findMessagesMeta', () => { + it('should delegate to next middleware', async () => { + const params = { cardId: 'card-123' as CardID } + + const result = await middleware.findMessagesMeta(session, params) + + expect(mockNext.findMessagesMeta).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ messageId: 'msg-1' }]) + }) + + it('should return empty array if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const params = { cardId: 'card-123' as CardID } + + const result = await middlewareWithoutNext.findMessagesMeta(session, params) + + expect(result).toEqual([]) + }) + }) + + describe('findMessagesGroups', () => { + it('should delegate to next middleware', async () => { + const params = { cardId: 'card-123' as CardID } + + const result = await middleware.findMessagesGroups(session, params) + + expect(mockNext.findMessagesGroups).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ blobId: 'blob-1' }]) + }) + + it('should return empty array if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const params = { cardId: 'card-123' as CardID } + + const result = await middlewareWithoutNext.findMessagesGroups(session, params) + + expect(result).toEqual([]) + }) + }) + + describe('findNotificationContexts', () => { + it('should delegate to next middleware', async () => { + const params = { cardId: 'card-123' as CardID, account: accountUuid } + const subscription = 'sub-123' + + const result = await middleware.findNotificationContexts(session, params, subscription) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith(session, params, subscription) + expect(result).toEqual([{ id: 'ctx-1' }]) + }) + + it('should return empty array if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const params = { cardId: 'card-123' as CardID } + + const result = await middlewareWithoutNext.findNotificationContexts(session, params) + + expect(result).toEqual([]) + }) + }) + + describe('findNotifications', () => { + it('should delegate to next middleware', async () => { + const params = { contextId: 'ctx-123' as ContextID } + const subscription = 'sub-123' + + const result = await middleware.findNotifications(session, params, subscription) + + expect(mockNext.findNotifications).toHaveBeenCalledWith(session, params, subscription) + expect(result).toEqual([{ id: 'notif-1' }]) + }) + + it('should return empty array if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const params = { contextId: 'ctx-123' as ContextID } + + const result = await middlewareWithoutNext.findNotifications(session, params) + + expect(result).toEqual([]) + }) + }) + + describe('findLabels', () => { + it('should delegate to next middleware', async () => { + const params = { cardId: 'card-123' as CardID } + const subscription = 'sub-123' + + const result = await middleware.findLabels(session, params, subscription) + + expect(mockNext.findLabels).toHaveBeenCalledWith(session, params, subscription) + expect(result).toEqual([{ labelId: 'label-1' }]) + }) + + it('should return empty array if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const params = { cardId: 'card-123' as CardID } + + const result = await middlewareWithoutNext.findLabels(session, params) + + expect(result).toEqual([]) + }) + }) + + describe('findCollaborators', () => { + it('should delegate to next middleware', async () => { + const params = { cardId: 'card-123' as CardID } + + const result = await middleware.findCollaborators(session, params) + + expect(mockNext.findCollaborators).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ account: accountUuid }]) + }) + + it('should return empty array if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const params = { cardId: 'card-123' as CardID } + + const result = await middlewareWithoutNext.findCollaborators(session, params) + + expect(result).toEqual([]) + }) + }) + + describe('findPeers', () => { + it('should delegate to next middleware', async () => { + const params = { workspaceId: workspace, cardId: 'card-123' as CardID } + + const result = await middleware.findPeers(session, params) + + expect(mockNext.findPeers).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ cardId: 'card-1' }]) + }) + + it('should return empty array if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const params = { workspaceId: workspace, cardId: 'card-123' as CardID } + + const result = await middlewareWithoutNext.findPeers(session, params) + + expect(result).toEqual([]) + }) + }) + + describe('handleBroadcast', () => { + it('should delegate to next middleware', () => { + const events: Enriched[] = [ + { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + date: new Date(), + _eventExtra: {} + } + ] + + middleware.handleBroadcast(session, events) + + expect(mockNext.handleBroadcast).toHaveBeenCalledWith(session, events) + }) + + it('should do nothing if no next middleware', () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const events: Enriched[] = [ + { + type: MessageEventType.CreateMessage, + date: new Date(), + _eventExtra: {} + } + ] + + expect(() => { + middlewareWithoutNext.handleBroadcast(session, events) + }).not.toThrow() + }) + }) + + describe('subscribeCard', () => { + it('should delegate to next middleware', () => { + const cardId = 'card-123' as CardID + const subscription = 'sub-123' + + middleware.subscribeCard(session, cardId, subscription) + + expect(mockNext.subscribeCard).toHaveBeenCalledWith(session, cardId, subscription) + }) + + it('should do nothing if no next middleware', () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const cardId = 'card-123' as CardID + const subscription = 'sub-123' + + expect(() => { + middlewareWithoutNext.subscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + }) + + describe('unsubscribeCard', () => { + it('should delegate to next middleware', () => { + const cardId = 'card-123' as CardID + const subscription = 'sub-123' + + middleware.unsubscribeCard(session, cardId, subscription) + + expect(mockNext.unsubscribeCard).toHaveBeenCalledWith(session, cardId, subscription) + }) + + it('should do nothing if no next middleware', () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const cardId = 'card-123' as CardID + const subscription = 'sub-123' + + expect(() => { + middlewareWithoutNext.unsubscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + }) + + describe('close', () => { + it('should call close without errors', () => { + expect(() => { + middleware.close() + }).not.toThrow() + }) + }) + + describe('closeSession', () => { + it('should call closeSession without errors', () => { + expect(() => { + middleware.closeSession('session-123') + }).not.toThrow() + }) + }) +}) diff --git a/packages/server/src/__tests__/middleware/broadcast.test.ts b/packages/server/src/__tests__/middleware/broadcast.test.ts new file mode 100644 index 0000000..20b1e05 --- /dev/null +++ b/packages/server/src/__tests__/middleware/broadcast.test.ts @@ -0,0 +1,1071 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type MeasureContext, type WorkspaceUuid } from '@hcengineering/core' +import { + AddCollaboratorsEvent, + AttachmentPatchEvent, + BlobPatchEvent, + CardEventType, + CreateLabelEvent, + CreateMessageEvent, + CreateNotificationContextEvent, + CreateNotificationEvent, + CreatePeerEvent, + LabelEventType, + MessageEventType, + NotificationEventType, + PeerEventType, + ReactionPatchEvent, + RemoveCardEvent, + RemoveCollaboratorsEvent, + RemoveLabelEvent, + RemoveNotificationContextEvent, + RemoveNotificationsEvent, + RemovePatchEvent, + RemovePeerEvent, + type SessionData, + ThreadPatchEvent, + TranslateMessageEvent, + UpdateCardTypeEvent, + UpdateNotificationContextEvent, + UpdateNotificationEvent, + UpdatePatchEvent +} from '@hcengineering/communication-sdk-types' +import { + type AccountUuid, + CardID, + type CardType, + ContextID, + Label, + LabelID, + type Markdown, + MessageType, + NotificationContext, + type SocialID +} from '@hcengineering/communication-types' + +import { BroadcastMiddleware } from '../../middleware/broadcast' +import { type CommunicationCallbacks, type Enriched, Middleware, type MiddlewareContext } from '../../types' +import { type LowLevelClient } from '../../client' + +describe('BroadcastMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockCallbacks: jest.Mocked + let mockNext: any + let session: SessionData + let session2: SessionData + let middleware: BroadcastMiddleware + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const accountUuid2 = 'account-456' as AccountUuid + const socialId = 'social-123' as SocialID + const socialId2 = 'social-456' as SocialID + const cardId = 'card-123' as CardID + const cardId2 = 'card-456' as CardID + + const basicEvent = { + _id: 'event-123', + _eventExtra: {}, + socialId, + date: new Date() + } as const + + // Helper function to create a complete CreateMessage event for session initialization + const createSessionEvent = (_cardId: CardID = cardId): Enriched => ({ + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: _cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown, + socialId, + date: new Date() + }) + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis(), + contextData: {} + } as any as jest.Mocked + + mockClient = { + db: {}, + blob: {} + } as unknown as jest.Mocked + + mockCallbacks = { + broadcast: jest.fn(), + enqueue: jest.fn(), + registerAsyncRequest: jest.fn() + } as any as jest.Mocked + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123', + contextData: {} + } as any as SessionData + + session2 = { + account: { + uuid: accountUuid2, + socialIds: [socialId2] + }, + sessionId: 'session-456', + contextData: {} + } as any as SessionData + + middleware = new BroadcastMiddleware(mockCallbacks, mockContext, mockNext) + }) + + describe('event', () => { + it('should create session and process event', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should handle derived events', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown, + socialId + } + + await middleware.event(session, event, true) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + + it('should create session for multiple users', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown, + socialId + } + + await middleware.event(session, event, false) + await middleware.event(session2, event, false) + + expect(mockNext.event).toHaveBeenCalledTimes(2) + }) + }) + + describe('findNotificationContexts', () => { + it('should create session and find notification contexts', async () => { + const params = { cardId } + const contexts = [{ id: 'ctx-1', cardId, account: accountUuid }] + mockNext.findNotificationContexts.mockResolvedValue(contexts) + + const result = await middleware.findNotificationContexts(session, params) + + expect(result).toEqual(contexts) + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith(session, params, undefined) + }) + + it('should subscribe to contexts cards when subscription provided', async () => { + const params = { cardId } + const subscription = 'sub-123' + const contexts = [ + { id: 'ctx-1' as ContextID, cardId: 'card-1' as CardID, account: accountUuid }, + { id: 'ctx-2' as ContextID, cardId: 'card-2' as CardID, account: accountUuid } + ] + mockNext.findNotificationContexts.mockResolvedValue(contexts) + + await middleware.findNotificationContexts(session, params, subscription) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith(session, params, subscription) + }) + + it('should auto-subscribe to all returned context cards', async () => { + const params = { cardId } + const subscription = 'sub-123' + const contexts = [ + { id: 'ctx-1' as ContextID, cardId: 'card-1' as CardID, account: accountUuid }, + { id: 'ctx-2' as ContextID, cardId: 'card-2' as CardID, account: accountUuid }, + { id: 'ctx-3' as ContextID, cardId: 'card-3' as CardID, account: accountUuid } + ] + mockNext.findNotificationContexts.mockResolvedValue(contexts) + + await middleware.findNotificationContexts(session, params, subscription) + + // Verify that subscription tracking is working by checking broadcast behavior + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-1' as CardID, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should not subscribe when no subscription provided', async () => { + const params = { cardId } + const contexts = [{ id: 'ctx-1' as ContextID, cardId, account: accountUuid }] + mockNext.findNotificationContexts.mockResolvedValue(contexts) + + await middleware.findNotificationContexts(session, params) + + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + + it('should not subscribe when sessionId is empty', async () => { + const sessionWithoutId: SessionData = { + ...session, + sessionId: '' + } as any + + const params = { cardId } + const subscription = 'sub-123' + const contexts: NotificationContext[] = [{ id: 'ctx-1' as ContextID, cardId, account: accountUuid }] as any[] + mockNext.findNotificationContexts.mockResolvedValue(contexts) + + await middleware.findNotificationContexts(sessionWithoutId, params, subscription) + + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + + it('should not subscribe when sessionId is null', async () => { + const sessionWithoutId: SessionData = { + ...session, + sessionId: null + } as any + + const params = { cardId } + const subscription = 'sub-123' + const contexts: NotificationContext[] = [{ id: 'ctx-1' as ContextID, cardId, account: accountUuid }] as any[] + mockNext.findNotificationContexts.mockResolvedValue(contexts) + + await middleware.findNotificationContexts(sessionWithoutId, params, subscription) + + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + }) + + describe('findNotifications', () => { + it('should create session and find notifications', async () => { + const params = { contextId: 'ctx-123' as ContextID } + const notifications = [{ id: 'notif-1' }] + mockNext.findNotifications.mockResolvedValue(notifications) + + const result = await middleware.findNotifications(session, params) + + expect(result).toEqual(notifications) + expect(mockNext.findNotifications).toHaveBeenCalledWith(session, params, undefined) + }) + + it('should pass query id when provided', async () => { + const params = { contextId: 'ctx-123' as ContextID } + const queryId = 'query-456' + const notifications = [{ id: 'notif-1' }] + mockNext.findNotifications.mockResolvedValue(notifications) + + await middleware.findNotifications(session, params, queryId) + + expect(mockNext.findNotifications).toHaveBeenCalledWith(session, params, queryId) + }) + }) + + describe('findLabels', () => { + it('should create session and find labels', async () => { + const params = { cardId } + const labels: Label[] = [{ labelId: 'label-1' as LabelID, cardId }] as any[] + mockNext.findLabels.mockResolvedValue(labels) + + const result = await middleware.findLabels(session, params) + + expect(result).toEqual(labels) + expect(mockNext.findLabels).toHaveBeenCalledWith(session, params, undefined) + }) + + it('should pass query id when provided', async () => { + const params = { cardId } + const queryId = 'query-789' + mockNext.findLabels.mockResolvedValue([]) + + await middleware.findLabels(session, params, queryId) + + expect(mockNext.findLabels).toHaveBeenCalledWith(session, params, queryId) + }) + }) + + describe('subscribeCard', () => { + it('should track subscription for card', () => { + const subscription = 'sub-123' + + middleware.subscribeCard(session, cardId, subscription) + + expect(() => { + middleware.subscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + + it('should handle multiple subscriptions for same card', () => { + const subscription1 = 'sub-1' + const subscription2 = 'sub-2' + + middleware.subscribeCard(session, cardId, subscription1) + middleware.subscribeCard(session, cardId, subscription2) + + expect(() => { + middleware.subscribeCard(session, cardId, subscription1) + }).not.toThrow() + }) + + it('should handle subscriptions for different cards', () => { + const subscription = 'sub-123' + + middleware.subscribeCard(session, cardId, subscription) + middleware.subscribeCard(session, cardId2, subscription) + + expect(() => { + middleware.subscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + + it('should handle subscription with numeric ID', () => { + const subscription = 12345 + + middleware.subscribeCard(session, cardId, subscription) + + expect(() => { + middleware.subscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + + it('should not subscribe when session has no sessionId', () => { + const sessionWithoutId: SessionData = { + ...session, + sessionId: null + } as any + const subscription = 'sub-123' + + expect(() => { + middleware.subscribeCard(sessionWithoutId, cardId, subscription) + }).not.toThrow() + }) + }) + + describe('unsubscribeCard', () => { + it('should remove subscription from card', () => { + const subscription = 'sub-123' + + middleware.subscribeCard(session, cardId, subscription) + middleware.unsubscribeCard(session, cardId, subscription) + + expect(() => { + middleware.unsubscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + + it('should handle unsubscribe when not subscribed', () => { + const subscription = 'sub-123' + + middleware.unsubscribeCard(session, cardId, subscription) + + expect(() => { + middleware.unsubscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + + it('should handle unsubscribe without sessionId', () => { + const sessionWithoutId: SessionData = { + ...session, + sessionId: null + } as any + const subscription = 'sub-123' + + middleware.unsubscribeCard(sessionWithoutId, cardId, subscription) + + expect(() => { + middleware.unsubscribeCard(sessionWithoutId, cardId, subscription) + }).not.toThrow() + }) + + it('should handle unsubscribe for non-existent session', () => { + const subscription = 'sub-123' + + expect(() => { + middleware.unsubscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + + it('should handle unsubscribe for non-existent card', () => { + const subscription = 'sub-123' + + // Create session first + middleware.subscribeCard(session, cardId, subscription) + + // Try to unsubscribe from different card + expect(() => { + middleware.unsubscribeCard(session, cardId2, subscription) + }).not.toThrow() + }) + + it('should only remove specific subscription', async () => { + const subscription1 = 'sub-1' + const subscription2 = 'sub-2' + + // Create session first + await middleware.event(session, createSessionEvent(cardId), false) + + middleware.subscribeCard(session, cardId, subscription1) + middleware.subscribeCard(session, cardId, subscription2) + middleware.unsubscribeCard(session, cardId, subscription1) + + // Verify subscription2 is still active by checking broadcast + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + }) + + describe('handleBroadcast', () => { + it('should call broadcast and enqueue', async () => { + // Create session first + await middleware.event(session, createSessionEvent(cardId), false) + + const events: Enriched[] = [ + { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + ] + + middleware.subscribeCard(session, cardId, 'sub-123') + middleware.handleBroadcast(session, events) + + expect(mockCallbacks.broadcast).toHaveBeenCalled() + expect(mockCallbacks.enqueue).toHaveBeenCalledWith(expect.anything(), events) + }) + + it('should handle empty events array', () => { + middleware.handleBroadcast(session, []) + + expect(mockCallbacks.broadcast).not.toHaveBeenCalled() + expect(mockCallbacks.enqueue).not.toHaveBeenCalled() + }) + + it('should filter events by subscription', async () => { + // Create session first + await middleware.event(session, createSessionEvent(cardId), false) + + middleware.subscribeCard(session, cardId, 'sub-123') + + const subscribedEvent: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + const unsubscribedEvent: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: cardId2, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + middleware.handleBroadcast(session, [subscribedEvent, unsubscribedEvent]) + + expect(mockCallbacks.broadcast).toHaveBeenCalled() + const broadcastCall = mockCallbacks.broadcast.mock.calls[0][1] + expect(broadcastCall[session.sessionId ?? '']).toEqual([subscribedEvent]) + }) + + it('should broadcast to multiple sessions', async () => { + // Create sessions first + await middleware.event(session, createSessionEvent(cardId), false) + await middleware.event(session2, createSessionEvent(cardId), false) + + middleware.subscribeCard(session, cardId, 'sub-1') + middleware.subscribeCard(session2, cardId, 'sub-2') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + middleware.handleBroadcast(session, [event]) + + expect(mockCallbacks.broadcast).toHaveBeenCalled() + const broadcastCall = mockCallbacks.broadcast.mock.calls[0][1] + expect(broadcastCall[session.sessionId ?? '']).toEqual([event]) + expect(broadcastCall[session2.sessionId ?? '']).toEqual([event]) + }) + + it('should handle MessageEventType.ThreadPatch', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ThreadPatch, + cardId + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle MessageEventType.ReactionPatch', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ReactionPatch, + cardId + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle MessageEventType.BlobPatch', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.BlobPatch, + cardId + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle MessageEventType.AttachmentPatch', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.AttachmentPatch, + cardId + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle MessageEventType.RemovePatch', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.RemovePatch, + cardId + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle MessageEventType.UpdatePatch', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle MessageEventType.TranslateMessage', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: MessageEventType.TranslateMessage, + cardId + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle NotificationEventType.CreateNotification for matching account', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.CreateNotification, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + + expect(mockCallbacks.broadcast).toHaveBeenCalled() + const broadcastCall = mockCallbacks.broadcast.mock.calls[0][1] + expect(broadcastCall[session.sessionId ?? '']).toEqual([event]) + }) + + it('should not broadcast NotificationEventType.CreateNotification for different account', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.CreateNotification, + account: accountUuid2 + } as any + + middleware.handleBroadcast(session, [event]) + + // Enqueue is still called + expect(mockCallbacks.enqueue).toHaveBeenCalled() + // No sessions should receive this event (either undefined or empty array) + const broadcastCall = mockCallbacks.broadcast.mock.calls[0]?.[1] + const sessionEvents = broadcastCall?.[session.sessionId ?? ''] + expect(sessionEvents === undefined || sessionEvents.length === 0).toBe(true) + }) + + it('should handle NotificationEventType.UpdateNotification', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.UpdateNotification, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle NotificationEventType.RemoveNotifications', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.RemoveNotifications, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle NotificationEventType.CreateNotificationContext', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.CreateNotificationContext, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle NotificationEventType.UpdateNotificationContext', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.UpdateNotificationContext, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle NotificationEventType.RemoveNotificationContext', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.RemoveNotificationContext, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should broadcast NotificationEventType.AddCollaborators to all sessions', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.AddCollaborators + } as any + + middleware.handleBroadcast(session, [event]) + + expect(mockCallbacks.broadcast).toHaveBeenCalled() + const broadcastCall = mockCallbacks.broadcast.mock.calls[0][1] + expect(broadcastCall[session.sessionId ?? '']).toEqual([event]) + }) + + it('should broadcast NotificationEventType.RemoveCollaborators to all sessions', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.RemoveCollaborators + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle LabelEventType.CreateLabel for matching account', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: LabelEventType.CreateLabel, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle LabelEventType.RemoveLabel for matching account', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: LabelEventType.RemoveLabel, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should broadcast CardEventType.UpdateCardType to all sessions', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: CardEventType.UpdateCardType + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should broadcast CardEventType.RemoveCard to all sessions', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: CardEventType.RemoveCard + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should not broadcast PeerEventType.CreatePeer', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: PeerEventType.CreatePeer + } as any + + middleware.handleBroadcast(session, [event]) + + // Enqueue is still called + expect(mockCallbacks.enqueue).toHaveBeenCalled() + // No sessions should receive this event (either undefined or empty array) + const broadcastCall = mockCallbacks.broadcast.mock.calls[0]?.[1] + const sessionEvents = broadcastCall?.[session.sessionId ?? ''] + expect(sessionEvents === undefined || sessionEvents.length === 0).toBe(true) + }) + + it('should not broadcast PeerEventType.RemovePeer', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: PeerEventType.RemovePeer + } as any + + middleware.handleBroadcast(session, [event]) + + // Enqueue is still called + expect(mockCallbacks.enqueue).toHaveBeenCalled() + // No sessions should receive this event (either undefined or empty array) + const broadcastCall = mockCallbacks.broadcast.mock.calls[0]?.[1] + const sessionEvents = broadcastCall?.[session.sessionId ?? ''] + expect(sessionEvents === undefined || sessionEvents.length === 0).toBe(true) + }) + + it('should handle broadcast errors gracefully', async () => { + mockCallbacks.broadcast.mockImplementation(() => { + throw new Error('Broadcast error') + }) + + await middleware.event(session, createSessionEvent(cardId), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + expect(() => { + middleware.handleBroadcast(session, [event]) + }).not.toThrow() + + expect(mockMeasureCtx.error).toHaveBeenCalledWith( + 'Failed to broadcast event', + expect.objectContaining({ error: expect.any(Error) }) + ) + }) + + it('should handle enqueue errors gracefully', async () => { + mockCallbacks.enqueue.mockImplementation(() => { + throw new Error('Enqueue error') + }) + + await middleware.event(session, createSessionEvent(cardId), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + expect(() => { + middleware.handleBroadcast(session, [event]) + }).not.toThrow() + + expect(mockMeasureCtx.error).toHaveBeenCalledWith( + 'Failed to broadcast event', + expect.objectContaining({ error: expect.any(Error) }) + ) + }) + + it('should pass context data to broadcast', async () => { + await middleware.event(session, createSessionEvent(cardId), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + middleware.handleBroadcast(session, [event]) + + expect(mockMeasureCtx.newChild).toHaveBeenCalledWith('enqueue', {}) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + expect(mockCallbacks.enqueue).toHaveBeenCalled() + }) + }) + + describe('close and closeSession', () => { + it('should handle close', async () => { + await middleware.event(session, createSessionEvent(cardId), false) + await middleware.event(session2, createSessionEvent(cardId), false) + + middleware.subscribeCard(session, cardId, 'sub-123') + middleware.subscribeCard(session2, cardId, 'sub-456') + + expect(() => { + middleware.close() + }).not.toThrow() + }) + + it('should clear all sessions on close', async () => { + await middleware.event(session, createSessionEvent(cardId), false) + await middleware.event(session2, createSessionEvent(cardId), false) + + middleware.subscribeCard(session, cardId, 'sub-123') + middleware.subscribeCard(session2, cardId, 'sub-456') + + middleware.close() + + // After close, broadcast should not have any sessions + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + middleware.handleBroadcast(session, [event]) + + // Enqueue is still called but broadcast shouldn't be (no sessions) + expect(mockCallbacks.enqueue).toHaveBeenCalled() + // broadcast should not be called since there are no sessions + const broadcastCalls = mockCallbacks.broadcast.mock.calls + if (broadcastCalls.length > 0) { + const lastBroadcastCall = broadcastCalls[broadcastCalls.length - 1][1] + expect(Object.keys(lastBroadcastCall)).toHaveLength(0) + } + }) + + it('should handle closeSession', async () => { + await middleware.event(session, createSessionEvent(cardId), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + expect(() => { + middleware.closeSession('session-123') + }).not.toThrow() + }) + + it('should remove specific session on closeSession', async () => { + await middleware.event(session, createSessionEvent(cardId), false) + await middleware.event(session2, createSessionEvent(cardId), false) + + middleware.subscribeCard(session, cardId, 'sub-123') + middleware.subscribeCard(session2, cardId, 'sub-456') + + middleware.closeSession('session-123') + + // After closeSession, only session2 should receive broadcasts + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + middleware.handleBroadcast(session, [event]) + + expect(mockCallbacks.broadcast).toHaveBeenCalled() + const broadcastCall = mockCallbacks.broadcast.mock.calls[0][1] + expect(broadcastCall[session.sessionId ?? '']).toBeUndefined() + expect(broadcastCall[session2.sessionId ?? '']).toEqual([event]) + }) + + it('should handle closeSession for non-existent session', () => { + expect(() => { + middleware.closeSession('non-existent-session') + }).not.toThrow() + }) + }) +}) diff --git a/packages/server/src/__tests__/middleware/date.test.ts b/packages/server/src/__tests__/middleware/date.test.ts new file mode 100644 index 0000000..252ac63 --- /dev/null +++ b/packages/server/src/__tests__/middleware/date.test.ts @@ -0,0 +1,249 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, systemAccountUuid, WorkspaceUuid } from '@hcengineering/core' +import { Event, MessageEventType, SessionData } from '@hcengineering/communication-sdk-types' +import { + AccountUuid, + CardID, + CardType, + Markdown, + MessageID, + MessageType, + SocialID +} from '@hcengineering/communication-types' + +import { DateMiddleware } from '../../middleware/date' +import { Enriched, Middleware, MiddlewareContext } from '../../types' +import { LowLevelClient } from '../../client' + +describe('DateMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: jest.Mocked + let session: SessionData + let middleware: DateMiddleware + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + const basicEvent = { + _id: 'event-123', + _eventExtra: {}, + socialId, + date: new Date() + } as const + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + mockClient = { + db: {}, + blob: {}, + findPersonUuid: jest.fn() + } as any as jest.Mocked + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123', + asyncData: [] + } as any as SessionData + + middleware = new DateMiddleware(mockContext, mockNext) + }) + + describe('event', () => { + it('should set date when not provided and not derived', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + const beforeTime = new Date().getTime() + await middleware.event(session, event, false) + const afterTime = new Date().getTime() + + expect(event.date).toBeDefined() + expect(event.date).toBeInstanceOf(Date) + expect(event.date.getTime()).toBeGreaterThanOrEqual(beforeTime) + expect(event.date.getTime()).toBeLessThanOrEqual(afterTime) + expect(event._eventExtra).toBeDefined() + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should preserve date when derived is true and date is provided', async () => { + const customDate = new Date('2025-01-01T00:00:00Z') + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId, + date: customDate + } + + await middleware.event(session, event, true) + + expect(event.date).toEqual(customDate) + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + + it('should preserve date when user is system account', async () => { + const customDate = new Date('2025-01-01T00:00:00Z') + const systemSession: SessionData = { + ...session, + account: { + uuid: systemAccountUuid, + socialIds: [socialId] + } + } as any as SessionData + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId, + date: customDate + } + + await middleware.event(systemSession, event, false) + + expect(event.date).toEqual(customDate) + expect(mockNext.event).toHaveBeenCalledWith(systemSession, event, false) + }) + + it('should set date even when null is provided for non-system account', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId, + date: null as any + } + + await middleware.event(session, event, false) + + expect(event.date).toBeDefined() + expect(event.date).toBeInstanceOf(Date) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should initialize _eventExtra if not present', async () => { + const event: Enriched = { + ...basicEvent, + _eventExtra: undefined as any, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId + } + + await middleware.event(session, event, false) + + expect(event._eventExtra).toBeDefined() + expect(event._eventExtra).toEqual({}) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should preserve existing _eventExtra', async () => { + const existingExtra = { someData: 'value' } + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId, + _eventExtra: existingExtra + } + + await middleware.event(session, event, false) + + expect(event._eventExtra).toEqual(existingExtra) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should handle events without date property', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.RemovePatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123' as MessageID, + socialId, + date: undefined as any + } + + await middleware.event(session, event, false) + + expect(event.date).toBeDefined() + expect(event.date).toBeInstanceOf(Date) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + }) +}) diff --git a/packages/server/src/__tests__/middleware/id.test.ts b/packages/server/src/__tests__/middleware/id.test.ts new file mode 100644 index 0000000..8f88e8b --- /dev/null +++ b/packages/server/src/__tests__/middleware/id.test.ts @@ -0,0 +1,236 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, WorkspaceUuid } from '@hcengineering/core' +import { Event, MessageEventType, ReactionPatchEvent, SessionData } from '@hcengineering/communication-sdk-types' +import { + AccountUuid, + CardID, + CardType, Emoji, + Markdown, + MessageID, + MessageType, + SocialID +} from '@hcengineering/communication-types' + +import { IdMiddleware } from '../../middleware/id' +import { Enriched, MiddlewareContext, Middleware } from '../../types' +import { LowLevelClient } from '../../client' + +describe('IdMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: jest.Mocked + let session: SessionData + let middleware: IdMiddleware + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + const basicEvent = { + _id: 'event-123', + _eventExtra: {}, + socialId, + date: new Date() + } as const + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + mockClient = { + db: {}, + blob: {} + } as any as jest.Mocked + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123', + asyncData: [] + } as any as SessionData + + middleware = new IdMiddleware(mockContext, mockNext) + }) + + describe('CreateMessage events', () => { + it('should generate messageId when not provided', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test message' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event, false) + + expect(event.messageId).toBeDefined() + expect(typeof event.messageId).toBe('string') + // @ts-expect-error check messageId + expect(event.messageId.length).toBeGreaterThan(0) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should generate unique messageIds for different events', async () => { + const event1: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'First message' as Markdown, + messageType: MessageType.Text + } + + const event2: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Second message' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event1, false) + await middleware.event(session, event2, false) + + expect(event1.messageId).toBeDefined() + expect(event2.messageId).toBeDefined() + expect(event1.messageId).not.toBe(event2.messageId) + }) + + it('should preserve existing messageId', async () => { + const existingMessageId = 'existing-msg-id' as MessageID + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test message' as Markdown, + messageType: MessageType.Text, + messageId: existingMessageId + } + + await middleware.event(session, event, false) + + expect(event.messageId).toBe(existingMessageId) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should generate messageId for derived CreateMessage events', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event, true) + + expect(event.messageId).toBeDefined() + expect(typeof event.messageId).toBe('string') + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + }) + + describe('Non-CreateMessage events', () => { + it('should not modify messageId for UpdatePatch events', async () => { + const messageId = 'msg-123' as MessageID + const event: Enriched = { + ...basicEvent, + type: MessageEventType.UpdatePatch, + cardId: 'card-123' as CardID, + messageId, + content: 'Updated' as Markdown + } + + await middleware.event(session, event, false) + + expect(event.messageId).toBe(messageId) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should not modify messageId for RemovePatch events', async () => { + const messageId = 'msg-123' as MessageID + const event: Enriched = { + ...basicEvent, + type: MessageEventType.RemovePatch, + cardId: 'card-123' as CardID, + messageId + } + + await middleware.event(session, event, false) + + expect(event.messageId).toBe(messageId) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should not modify messageId for ReactionPatch events', async () => { + const messageId = 'msg-123' as MessageID + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ReactionPatch, + cardId: 'card-123' as CardID, + messageId, + operation: { opcode: 'add', reaction: '👍' as Emoji } + } + + await middleware.event(session, event, false) + + expect(event.messageId).toBe(messageId) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + }) +}) diff --git a/packages/server/src/__tests__/middleware/indentity.test.ts b/packages/server/src/__tests__/middleware/indentity.test.ts new file mode 100644 index 0000000..81b843d --- /dev/null +++ b/packages/server/src/__tests__/middleware/indentity.test.ts @@ -0,0 +1,332 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, systemAccountUuid, WorkspaceUuid } from '@hcengineering/core' +import { Event, MessageEventType, SessionData } from '@hcengineering/communication-sdk-types' +import { + AccountUuid, + CardID, + CardType, + Emoji, + Markdown, + MessageID, + MessageType, + SocialID +} from '@hcengineering/communication-types' +import { IdentityMiddleware } from '../../middleware/indentity' +import { Enriched, Middleware, MiddlewareContext } from '../../types' +import { LowLevelClient } from '../../client' + +describe('IdentityMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: any + let session: SessionData + let middleware: IdentityMiddleware + let findPersonUuidMock: jest.MockedFunction + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + const basicEvent = { + _id: 'event-123', + _eventExtra: {}, + socialId, + date: new Date() + } as const + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + const findPersonUuidFn = jest.fn() + + mockClient = { + db: {}, + blob: {}, + findPersonUuid: findPersonUuidFn + } as any as jest.Mocked + + findPersonUuidMock = findPersonUuidFn + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123' + } as any as SessionData + + middleware = new IdentityMiddleware(mockContext, mockNext) + }) + + describe('event - personUuid enrichment', () => { + it('should set personUuid for ThreadPatch event', async () => { + const personUuid = 'person-123' + findPersonUuidMock.mockResolvedValue(personUuid as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ThreadPatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'attach', threadId: 'thread-123' as CardID, threadType: 'threadType' as CardType }, + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + + expect(event.personUuid).toBe(personUuid) + expect(findPersonUuidMock).toHaveBeenCalledWith( + { ctx: mockMeasureCtx, account: session.account }, + socialId + ) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should set personUuid for ReactionPatch event', async () => { + const personUuid = 'person-456' + findPersonUuidMock.mockResolvedValue(personUuid as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ReactionPatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'add', reaction: '👍' as Emoji } + } + + await middleware.event(session, event, false) + + expect(event.personUuid).toBe(personUuid) + expect(findPersonUuidMock).toHaveBeenCalledWith( + { ctx: mockMeasureCtx, account: session.account }, + socialId + ) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should not set personUuid for CreateMessage event', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event, false) + + expect((event as any).personUuid).toBeUndefined() + expect(findPersonUuidMock).not.toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should handle personUuid as undefined when not found', async () => { + findPersonUuidMock.mockResolvedValue(undefined) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ThreadPatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'update', threadId: 'thread-123' as CardID, update: { threadType: 'threadType' as CardType } } + } + + await middleware.event(session, event, false) + + expect(event.personUuid).toBeUndefined() + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should work with derived events', async () => { + const personUuid = 'person-789' + findPersonUuidMock.mockResolvedValue(personUuid as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ReactionPatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'remove', reaction: '❤️' as Emoji } + } + + await middleware.event(session, event, true) + + expect(event.personUuid).toBe(personUuid) + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + }) + + describe('findNotificationContexts - account enrichment', () => { + it('should enrich params with account for regular user', async () => { + const params = { cardId: 'card-123' as CardID } + await middleware.findNotificationContexts(session, params) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith( + session, + { ...params, account: accountUuid }, + undefined + ) + }) + + it('should not enrich params for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { uuid: systemAccountUuid, socialIds: [socialId] } + } as any as SessionData + + const params = { cardId: 'card-123' as CardID } + await middleware.findNotificationContexts(systemSession, params) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith(systemSession, params, undefined) + }) + + it('should overwrite existing account in params with session account', async () => { + const otherAccount = 'other-account' as AccountUuid + const params = { cardId: 'card-123' as CardID, account: otherAccount } + await middleware.findNotificationContexts(session, params) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith( + session, + { ...params, account: accountUuid }, // Session account overwrites the other account + undefined + ) + }) + + it('should pass subscription to next middleware', async () => { + const params = { cardId: 'card-123' as CardID } + const subscription = 'sub-123' + await middleware.findNotificationContexts(session, params, subscription) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith( + session, + { ...params, account: accountUuid }, + subscription + ) + }) + }) + + describe('findNotifications - account enrichment', () => { + it('should enrich params with account for regular user', async () => { + const params = { contextId: 'ctx-123' as any } + await middleware.findNotifications(session, params) + + expect(mockNext.findNotifications).toHaveBeenCalledWith( + session, + { ...params, account: accountUuid }, + undefined + ) + }) + + it('should not enrich params for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { uuid: systemAccountUuid, socialIds: [socialId] } + } as any as SessionData + + const params = { contextId: 'ctx-123' as any } + await middleware.findNotifications(systemSession, params) + + expect(mockNext.findNotifications).toHaveBeenCalledWith(systemSession, params, undefined) + }) + + it('should overwrite existing account in params with session account', async () => { + const otherAccount = 'other-account' as AccountUuid + const params = { contextId: 'ctx-123' as any, account: otherAccount } + await middleware.findNotifications(session, params) + + expect(mockNext.findNotifications).toHaveBeenCalledWith( + session, + { ...params, account: accountUuid }, // Session account overwrites the other account + undefined + ) + }) + }) + + describe('findLabels - account enrichment', () => { + it('should enrich params with account for regular user', async () => { + const params = { cardId: 'card-123' as CardID } + await middleware.findLabels(session, params) + + expect(mockNext.findLabels).toHaveBeenCalledWith( + session, + { ...params, account: accountUuid }, + undefined + ) + }) + + it('should not enrich params for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { uuid: systemAccountUuid, socialIds: [socialId] } + } as any as SessionData + + const params = { cardId: 'card-123' as CardID } + await middleware.findLabels(systemSession, params) + + expect(mockNext.findLabels).toHaveBeenCalledWith(systemSession, params, undefined) + }) + + it('should overwrite existing account in params with session account', async () => { + const otherAccount = 'other-account' as AccountUuid + const params = { cardId: 'card-123' as CardID, account: otherAccount } + await middleware.findLabels(session, params) + + expect(mockNext.findLabels).toHaveBeenCalledWith( + session, + { ...params, account: accountUuid }, // Session account overwrites the other account + undefined + ) + }) + }) +}) diff --git a/packages/server/src/__tests__/middleware/peer.test.ts b/packages/server/src/__tests__/middleware/peer.test.ts new file mode 100644 index 0000000..e272395 --- /dev/null +++ b/packages/server/src/__tests__/middleware/peer.test.ts @@ -0,0 +1,323 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, WorkspaceUuid } from '@hcengineering/core' +import { + AttachmentPatchEvent, + CreateMessageEvent, + CreatePeerEvent, + Event, + MessageEventType, + NotificationEventType, + PeerEventType, RemovePatchEvent, + SessionData, UpdatePatchEvent +} from '@hcengineering/communication-sdk-types' +import { + AccountUuid, + CardID, + CardType, + Markdown, + MessageID, + MessageType, + SocialID +} from '@hcengineering/communication-types' +import { PeerMiddleware } from '../../middleware/peer' +import { Enriched, Middleware, MiddlewareContext } from '../../types' +import { LowLevelClient } from '../../client' + +describe('PeerMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: any + let mockHead: any + let session: SessionData + let middleware: PeerMiddleware + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + const cardId = 'card-123' as CardID + + const basicEvent = { + _id: 'event-123', + _eventExtra: {}, + socialId, + date: new Date() + } as const + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + mockClient = { + db: {}, + blob: {} + } as any as jest.Mocked + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockHead = { + findPeers: jest.fn().mockResolvedValue([]), + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set(), + head: mockHead + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123' + } as any as SessionData + + middleware = new PeerMiddleware(mockContext, mockNext) + }) + + describe('CreatePeer events', () => { + it('should add cardId to cadsWithPeers on CreatePeer event', async () => { + const event: Enriched = { + ...basicEvent, + workspaceId: workspace, + kind: 'card', + type: PeerEventType.CreatePeer, + cardId, + value: '123' + } + + expect(mockContext.cadsWithPeers.has(cardId as any)).toBe(false) + + await middleware.event(session, event, false) + + expect(mockContext.cadsWithPeers.has(cardId as any)).toBe(true) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should work with derived CreatePeer events', async () => { + const event: Enriched = { + ...basicEvent, + workspaceId: workspace, + kind: 'card', + type: PeerEventType.CreatePeer, + cardId, + value: '123' + } + + await middleware.event(session, event, true) + + expect(mockContext.cadsWithPeers.has(cardId as any)).toBe(true) + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + }) + + describe('Message events with peers', () => { + beforeEach(() => { + mockContext.cadsWithPeers.add(cardId as any) + }) + + it('should fetch and attach peers for CreateMessage event', async () => { + const peers = [ + { kind: 'card', members: [{ workspaceId: workspace, cardId: 'peer-card' }] } + ] + mockHead.findPeers.mockResolvedValue(peers) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event, false) + + expect(mockHead.findPeers).toHaveBeenCalledWith(session, { + workspaceId: workspace, + cardId + }) + expect(event._eventExtra?.peers).toEqual(peers) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should fetch and attach peers for UpdatePatch event', async () => { + const peers = [{ kind: 'card', members: [] }] + mockHead.findPeers.mockResolvedValue(peers) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.UpdatePatch, + cardId, + messageId: 'msg-123' as MessageID, + content: 'Updated' as Markdown + } + + await middleware.event(session, event, false) + + expect(mockHead.findPeers).toHaveBeenCalledWith(session, { + workspaceId: workspace, + cardId + }) + expect(event._eventExtra?.peers).toEqual(peers) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should fetch and attach peers for RemovePatch event', async () => { + const peers = [{ kind: 'card', members: [] }] + mockHead.findPeers.mockResolvedValue(peers) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.RemovePatch, + cardId, + messageId: 'msg-123' as MessageID + } + + await middleware.event(session, event, false) + + expect(event._eventExtra?.peers).toEqual(peers) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should fetch and attach peers for AttachmentPatch event', async () => { + const peers = [{ kind: 'card', members: [] }] + mockHead.findPeers.mockResolvedValue(peers) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.AttachmentPatch, + cardId, + messageId: 'msg-123' as MessageID, + operations: [] + } + + await middleware.event(session, event, false) + + expect(event._eventExtra?.peers).toEqual(peers) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should initialize _eventExtra if not present', async () => { + const peers = [{ kind: 'card', members: [] }] + mockHead.findPeers.mockResolvedValue(peers) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event, false) + + expect(event._eventExtra).toBeDefined() + expect(event._eventExtra?.peers).toEqual(peers) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should handle errors when fetching peers', async () => { + mockHead.findPeers.mockRejectedValue(new Error('Fetch error')) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + await expect(middleware.event(session, event, false)).rejects.toThrow('Fetch error') + }) + }) + + describe('Message events without peers', () => { + it('should not fetch peers when cardId not in cadsWithPeers', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-456' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event, false) + + expect(mockHead.findPeers).not.toHaveBeenCalled() + expect(event._eventExtra?.peers).toEqual([{ kind: 'card', members: [] }]) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + }) + + describe('Non-message events', () => { + it('should not fetch peers for notification events', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.CreateNotification, + account: accountUuid + } as any as Enriched + + await middleware.event(session, event, false) + + expect(mockHead.findPeers).not.toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + }) +}) diff --git a/packages/server/src/__tests__/middleware/permissions.test.ts b/packages/server/src/__tests__/middleware/permissions.test.ts new file mode 100644 index 0000000..6adb9cb --- /dev/null +++ b/packages/server/src/__tests__/middleware/permissions.test.ts @@ -0,0 +1,759 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AccountRole, MeasureContext, systemAccountUuid, WorkspaceUuid } from '@hcengineering/core' +import { + Event, + MessageEventType, + NotificationEventType, + PeerEventType, + SessionData +} from '@hcengineering/communication-sdk-types' +import { + AccountUuid, + CardID, + CardType, ContextID, Emoji, + Markdown, + MessageID, + MessageType, NotificationID, + SocialID +} from '@hcengineering/communication-types' + +import { PermissionsMiddleware } from '../../middleware/permissions' +import { Enriched, Middleware, MiddlewareContext } from '../../types' +import { LowLevelClient } from '../../client' + +describe('PermissionsMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: any + let session: SessionData + let middleware: PermissionsMiddleware + let getMessageMetaMock: jest.MockedFunction + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + const cardId = 'card-123' as CardID + + const basicEvent = { + _id: 'event-123', + _eventExtra: {}, + socialId, + date: new Date() + } as const + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + const getMessageMetaFn = jest.fn() + + mockClient = { + db: {}, + blob: {}, + getMessageMeta: getMessageMetaFn + } as any as jest.Mocked + + getMessageMetaMock = getMessageMetaFn + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId], + role: AccountRole.User + }, + sessionId: 'session-123' + } as any as SessionData + + middleware = new PermissionsMiddleware(mockContext, mockNext) + }) + + describe('Derived events', () => { + it('should skip permission checks for derived events', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId: 'other-social' as SocialID + } + + await middleware.event(session, event, true) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + + it('should allow any operation for derived events', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.UpdatePatch, + cardId, + messageId: 'msg-123' as MessageID, + content: 'Updated' as Markdown, + socialId: 'other-social' as SocialID + } + + await middleware.event(session, event, true) + + expect(getMessageMetaMock).not.toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + }) + + describe('System account', () => { + it('should allow all operations for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { + uuid: systemAccountUuid, + socialIds: [socialId], + role: AccountRole.User + } + } as any as SessionData + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId: 'any-social' as SocialID + } + + await middleware.event(systemSession, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(systemSession, event, false) + }) + + it('should allow system account to use any socialId', async () => { + const systemSession: SessionData = { + ...session, + account: { + uuid: systemAccountUuid, + socialIds: [socialId], + role: AccountRole.User + } + } as any as SessionData + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ReactionPatch, + cardId, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'add', reaction: '👍' as Emoji }, + socialId: 'different-social' as SocialID + } + + await middleware.event(systemSession, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('CreateMessage events', () => { + it('should allow CreateMessage with correct socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should reject CreateMessage with incorrect socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId: 'other-social' as SocialID + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should force noNotify to false for non-system accounts', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + options: { noNotify: true } + } + + await middleware.event(session, event, false) + + expect(event.options?.noNotify).toBe(false) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should allow noNotify for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { + uuid: systemAccountUuid, + socialIds: [socialId], + role: AccountRole.User + } + } as any as SessionData + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + options: { noNotify: true } + } + + await middleware.event(systemSession, event, false) + + expect(event.options?.noNotify).toBe(true) + expect(mockNext.event).toHaveBeenCalledWith(systemSession, event, false) + }) + }) + + describe('UpdatePatch events', () => { + it('should allow UpdatePatch when user is message author', async () => { + const messageId = 'msg-123' as MessageID + getMessageMetaMock.mockResolvedValue({ + creator: socialId, + cardId, + id: messageId + } as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.UpdatePatch, + cardId, + messageId, + content: 'Updated' as Markdown + } + + await middleware.event(session, event, false) + + expect(getMessageMetaMock).toHaveBeenCalledWith(cardId, messageId) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should reject UpdatePatch when user is not message author', async () => { + const messageId = 'msg-123' as MessageID + getMessageMetaMock.mockResolvedValue({ + creator: 'other-social' as SocialID, + cardId, + id: messageId + } as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.UpdatePatch, + cardId, + messageId, + content: 'Updated' as Markdown + } + + await expect(middleware.event(session, event, false)).rejects.toThrow('message author is not allowed') + }) + + it('should reject UpdatePatch when message not found', async () => { + getMessageMetaMock.mockResolvedValue(undefined) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.UpdatePatch, + cardId, + messageId: 'msg-123' as MessageID, + content: 'Updated' as Markdown + } + + await expect(middleware.event(session, event, false)).rejects.toThrow('message not found') + }) + + it('should reject UpdatePatch with incorrect socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.UpdatePatch, + cardId, + messageId: 'msg-123' as MessageID, + content: 'Updated' as Markdown, + socialId: 'other-social' as SocialID + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + }) + + describe('RemovePatch events', () => { + it('should allow RemovePatch when user is message author', async () => { + const messageId = 'msg-123' as MessageID + getMessageMetaMock.mockResolvedValue({ + creator: socialId, + cardId, + id: messageId + } as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.RemovePatch, + cardId, + messageId + } + + await middleware.event(session, event, false) + + expect(getMessageMetaMock).toHaveBeenCalledWith(cardId, messageId) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject RemovePatch when user is not message author', async () => { + const messageId = 'msg-123' as MessageID + getMessageMetaMock.mockResolvedValue({ + creator: 'other-social' as SocialID, + cardId, + id: messageId + } as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.RemovePatch, + cardId, + messageId + } + + await expect(middleware.event(session, event, false)).rejects.toThrow('message author is not allowed') + }) + }) + + describe('AttachmentPatch and BlobPatch events', () => { + it('should allow AttachmentPatch when user is message author', async () => { + const messageId = 'msg-123' as MessageID + getMessageMetaMock.mockResolvedValue({ + creator: socialId, + cardId, + id: messageId + } as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.AttachmentPatch, + cardId, + messageId, + operations: [] + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should allow BlobPatch when user is message author', async () => { + const messageId = 'msg-123' as MessageID + getMessageMetaMock.mockResolvedValue({ + creator: socialId, + cardId, + id: messageId + } as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.BlobPatch, + cardId, + messageId, + operations: [] + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('ReactionPatch and ThreadPatch events', () => { + it('should allow ReactionPatch with correct socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ReactionPatch, + cardId, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'add', reaction: '👍' as Emoji } + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should reject ReactionPatch with incorrect socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ReactionPatch, + cardId, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'add', reaction: '👍' as Emoji }, + socialId: 'other-social' as SocialID + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should allow ThreadPatch with correct socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ThreadPatch, + cardId, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'attach', threadId: 'thread-123' as CardID, threadType: 'threadType' as CardType } + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject ThreadPatch with incorrect socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ThreadPatch, + cardId, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'attach', threadId: 'thread-123' as CardID, threadType: 'threadType' as CardType }, + socialId: 'other-social' as SocialID + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + }) + + describe('Notification events', () => { + it('should allow UpdateNotificationContext for own account', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.UpdateNotificationContext, + contextId: 'ctx-123' as ContextID, + account: accountUuid, + updates: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject UpdateNotificationContext for other account', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.UpdateNotificationContext, + contextId: 'ctx-123' as ContextID, + account: 'other-account' as AccountUuid, + updates: {} + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should allow RemoveNotifications for own account', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.RemoveNotifications, + contextId: 'ctx-123' as ContextID, + account: accountUuid, + ids: ['notif-1' as NotificationID] + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject RemoveNotifications for other account', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.RemoveNotifications, + contextId: 'ctx-123' as ContextID, + account: 'other-account' as AccountUuid, + ids: ['notif-1' as NotificationID] + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should allow UpdateNotification for own account', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.UpdateNotification, + contextId: 'ctx-123' as ContextID, + account: accountUuid, + updates: { read: true }, + query: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should allow RemoveNotificationContext for own account', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.RemoveNotificationContext, + contextId: 'ctx-123' as ContextID, + account: accountUuid + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('System-only operations', () => { + it('should reject TranslateMessage for non-system account', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.TranslateMessage, + cardId, + content: '', + messageId: 'msg-123' as MessageID, + language: 'en' + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should allow TranslateMessage for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { + uuid: systemAccountUuid, + socialIds: [socialId], + role: AccountRole.User + } + } as any as SessionData + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.TranslateMessage, + cardId, + messageId: 'msg-123' as MessageID, + language: 'en', + content: '' + } + + await middleware.event(systemSession, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject CreatePeer for non-system account', async () => { + const event: Enriched = { + ...basicEvent, + type: PeerEventType.CreatePeer, + workspaceId: workspace, + cardId, + value: '123', + kind: 'card' + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should allow CreatePeer for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { + uuid: systemAccountUuid, + socialIds: [socialId], + role: AccountRole.User + } + } as any as SessionData + + const event: Enriched = { + ...basicEvent, + type: PeerEventType.CreatePeer, + workspaceId: workspace, + cardId, + value: '123', + kind: 'card' + } + + await middleware.event(systemSession, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject RemovePeer for non-system account', async () => { + const event: Enriched = { + ...basicEvent, + type: PeerEventType.RemovePeer, + workspaceId: workspace, + cardId, + kind: 'card', + value: '123' + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should allow RemovePeer for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { + uuid: systemAccountUuid, + socialIds: [socialId], + role: AccountRole.User + } + } as any as SessionData + + const event: Enriched = { + ...basicEvent, + workspaceId: workspace, + type: PeerEventType.RemovePeer, + cardId, + kind: 'card', + value: '123' + } + + await middleware.event(systemSession, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('Collaborators events', () => { + it('should allow AddCollaborators with correct socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.AddCollaborators, + cardId, + cardType: 'task' as CardType, + collaborators: [accountUuid] + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject AddCollaborators with incorrect socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.AddCollaborators, + cardId, + cardType: 'task' as CardType, + collaborators: [accountUuid], + socialId: 'other-social' as SocialID + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should allow RemoveCollaborators with correct socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.RemoveCollaborators, + cardId, + cardType: 'task' as CardType, + collaborators: [accountUuid] + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject RemoveCollaborators with incorrect socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.RemoveCollaborators, + cardId, + cardType: 'task' as CardType, + collaborators: [accountUuid], + socialId: 'other-social' as SocialID + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + }) + + describe('Middleware chaining', () => { + it('should call next middleware and return its result', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + const expectedResult = { success: true, eventId: 'event-123' } + mockNext.event.mockResolvedValue(expectedResult) + + const result = await middleware.event(session, event, false) + + expect(result).toEqual(expectedResult) + expect(mockNext.event).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/server/src/__tests__/middleware/storage.test.ts b/packages/server/src/__tests__/middleware/storage.test.ts new file mode 100644 index 0000000..9d48a40 --- /dev/null +++ b/packages/server/src/__tests__/middleware/storage.test.ts @@ -0,0 +1,1156 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, WorkspaceUuid } from '@hcengineering/core' +import { MessageEventType, NotificationEventType, SessionData, LabelEventType, CardEventType, PeerEventType } from '@hcengineering/communication-sdk-types' +import { AccountUuid, CardType, Markdown, SocialID } from '@hcengineering/communication-types' +import { StorageMiddleware } from '../../middleware/storage' +import { Enriched, Middleware, MiddlewareContext } from '../../types' +import { LowLevelClient } from '../../client' + +describe('StorageMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: { + db: Record + blob: Record + getMessageMeta: jest.Mock + findPersonUuid: jest.Mock + removeMessageMeta: jest.Mock + } + let mockMeasureCtx: jest.Mocked + let mockNext: jest.Mocked + let session: SessionData + let middleware: StorageMiddleware + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + mockClient = { + db: { + findMessagesMeta: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + findThreadMeta: jest.fn().mockResolvedValue([]), + createMessageMeta: jest.fn().mockResolvedValue(true), + removeMessageMeta: jest.fn().mockResolvedValue(undefined), + addCollaborators: jest.fn().mockResolvedValue([]), + removeCollaborators: jest.fn().mockResolvedValue(undefined), + createNotification: jest.fn().mockResolvedValue('notif-123'), + updateNotification: jest.fn().mockResolvedValue(1), + removeNotifications: jest.fn().mockResolvedValue([]), + createNotificationContext: jest.fn().mockResolvedValue('ctx-123'), + removeContext: jest.fn().mockResolvedValue('ctx-123'), + updateContext: jest.fn().mockResolvedValue(undefined), + createLabel: jest.fn().mockResolvedValue(undefined), + removeLabels: jest.fn().mockResolvedValue(undefined), + createPeer: jest.fn().mockResolvedValue(undefined), + removePeer: jest.fn().mockResolvedValue(undefined), + attachThreadMeta: jest.fn().mockResolvedValue(undefined), + close: jest.fn() + }, + blob: { + findMessagesGroups: jest.fn().mockResolvedValue([]), + getMessageGroupByDate: jest.fn().mockResolvedValue({ blobId: 'blob-123' }), + insertMessage: jest.fn().mockResolvedValue(undefined), + updateMessage: jest.fn().mockResolvedValue(undefined), + removeMessage: jest.fn().mockResolvedValue(undefined), + addReaction: jest.fn().mockResolvedValue(undefined), + removeReaction: jest.fn().mockResolvedValue(undefined), + addAttachments: jest.fn().mockResolvedValue(undefined), + removeAttachments: jest.fn().mockResolvedValue(undefined), + setAttachments: jest.fn().mockResolvedValue(undefined), + updateAttachments: jest.fn().mockResolvedValue(undefined), + attachThread: jest.fn().mockResolvedValue(undefined), + detachThread: jest.fn().mockResolvedValue(undefined), + addBlobs: jest.fn().mockResolvedValue(undefined), + removeBlobs: jest.fn().mockResolvedValue(undefined), + updateBlobs: jest.fn().mockResolvedValue(undefined), + setBlobs: jest.fn().mockResolvedValue(undefined), + updateThread: jest.fn().mockResolvedValue(undefined), + addThreadReply: jest.fn().mockResolvedValue(undefined), + removeThreadReply: jest.fn().mockResolvedValue(undefined) + }, + getMessageMeta: jest.fn().mockResolvedValue({ blobId: 'blob-123', messageId: 'msg-123' }), + findPersonUuid: jest.fn().mockResolvedValue('person-123'), + removeMessageMeta: jest.fn().mockResolvedValue(undefined) + } + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient as any as LowLevelClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123' + } as any as SessionData + + middleware = new StorageMiddleware(mockContext, mockNext) + }) + + describe('findMessagesMeta', () => { + it('should query database for messages meta', async () => { + const params = { cardId: 'card-123' } + const expectedResult = [{ messageId: 'msg-1', blobId: 'blob-1' }] + mockClient.db.findMessagesMeta.mockResolvedValue(expectedResult as any) + + const result = await middleware.findMessagesMeta(session, params as any) + + expect(mockClient.db.findMessagesMeta).toHaveBeenCalledWith(params) + expect(result).toEqual(expectedResult) + }) + }) + + describe('findMessagesGroups', () => { + it('should query blob storage for messages groups', async () => { + const params = { cardId: 'card-123' } + const expectedResult = [{ blobId: 'blob-1', from: new Date(), to: new Date() }] + mockClient.blob.findMessagesGroups.mockResolvedValue(expectedResult as any) + + const result = await middleware.findMessagesGroups(session, params as any) + + expect(mockClient.blob.findMessagesGroups).toHaveBeenCalledWith(params) + expect(result).toEqual(expectedResult) + }) + + it('should use message meta blobId when id is provided', async () => { + const params = { cardId: 'card-123', id: 'msg-123' } + mockClient.getMessageMeta.mockResolvedValue({ blobId: 'blob-456', messageId: 'msg-123' } as any) + + await middleware.findMessagesGroups(session, params as any) + + expect(mockClient.getMessageMeta).toHaveBeenCalledWith('card-123', 'msg-123') + expect(mockClient.blob.findMessagesGroups).toHaveBeenCalledWith({ + ...params, + blobId: 'blob-456' + }) + }) + + it('should return empty array if message meta not found', async () => { + const params = { cardId: 'card-123', id: 'msg-123' } + mockClient.getMessageMeta.mockResolvedValue(undefined) + + const result = await middleware.findMessagesGroups(session, params as any) + + expect(result).toEqual([]) + }) + }) + + describe('findNotificationContexts', () => { + it('should query database for notification contexts', async () => { + const params = { cardId: 'card-123', account: accountUuid } + const expectedResult = [{ id: 'ctx-1', cardId: 'card-123' }] + mockClient.db.findNotificationContexts.mockResolvedValue(expectedResult as any) + + const result = await middleware.findNotificationContexts(session, params as any) + + expect(mockClient.db.findNotificationContexts).toHaveBeenCalledWith(params + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('findNotifications', () => { + it('should query database for notifications', async () => { + const params = { contextId: 'ctx-123' } + const expectedResult = [{ id: 'notif-1', contextId: 'ctx-123' }] + mockClient.db.findNotifications.mockResolvedValue(expectedResult as any) + + const result = await middleware.findNotifications(session, params as any) + + expect(mockClient.db.findNotifications).toHaveBeenCalledWith(params) + expect(result).toEqual(expectedResult) + }) + }) + + describe('findLabels', () => { + it('should query database for labels', async () => { + const params = { cardId: 'card-123' } + const expectedResult = [{ labelId: 'label-1', cardId: 'card-123' }] + mockClient.db.findLabels.mockResolvedValue(expectedResult as any) + + const result = await middleware.findLabels(session, params as any) + + expect(mockClient.db.findLabels).toHaveBeenCalledWith(params) + expect(result).toEqual(expectedResult) + }) + }) + + describe('findCollaborators', () => { + it('should query database for collaborators', async () => { + const params = { cardId: 'card-123' } + const expectedResult = [{ account: accountUuid, cardId: 'card-123' }] + mockClient.db.findCollaborators.mockResolvedValue(expectedResult as any) + + const result = await middleware.findCollaborators(session, params as any) + + expect(mockClient.db.findCollaborators).toHaveBeenCalledWith(params) + expect(result).toEqual(expectedResult) + }) + }) + + describe('findPeers', () => { + it('should query database for peers', async () => { + const params = { workspaceId: workspace, cardId: 'card-123' } + const expectedResult = [{ workspaceId: workspace, cardId: 'card-123' }] + mockClient.db.findPeers.mockResolvedValue(expectedResult as any) + + const result = await middleware.findPeers(session, params as any) + + expect(mockClient.db.findPeers).toHaveBeenCalledWith(params) + expect(result).toEqual(expectedResult) + }) + }) + + describe('event - CreateMessage', () => { + it('should create message in storage', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + socialId, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test message' as Markdown, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.getMessageGroupByDate).toHaveBeenCalledWith('card-123', event.date) + expect(mockClient.db.createMessageMeta).toHaveBeenCalledWith( + 'card-123', + 'msg-123', + socialId, + event.date, + 'blob-123' + ) + expect(mockClient.blob.insertMessage).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should skip propagate if message already exists', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + socialId, + date: new Date(), + _eventExtra: {} + } + + mockClient.db.createMessageMeta.mockResolvedValue(false) + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + + it('should throw error if messageId is missing', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + socialId, + date: new Date(), + _eventExtra: {} + } + + await expect(middleware.event(session, event, false)).rejects.toThrow('Message id is required') + }) + + it('should throw error if message group not found', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + socialId, + date: new Date(), + _eventExtra: {} + } + + mockClient.blob.getMessageGroupByDate.mockResolvedValue(null) + + await expect(middleware.event(session, event, false)).rejects.toThrow('Cannot create message') + }) + }) + + describe('event - UpdatePatch', () => { + it('should update message in storage', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123', + messageId: 'msg-123', + content: 'Updated content' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.getMessageMeta).toHaveBeenCalledWith('card-123', 'msg-123') + expect(mockClient.blob.updateMessage).toHaveBeenCalledWith( + 'card-123', + 'blob-123', + 'msg-123', + { + content: event.content, + extra: event.extra, + language: event.language + }, + event.date + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if message not found', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123', + messageId: 'msg-123', + content: 'Updated' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + mockClient.getMessageMeta.mockResolvedValue(undefined) + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + }) + + describe('event - RemovePatch', () => { + it('should remove message from storage', async () => { + const event: Enriched = { + type: MessageEventType.RemovePatch, + cardId: 'card-123', + messageId: 'msg-123', + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.getMessageMeta).toHaveBeenCalledWith('card-123', 'msg-123') + expect(mockClient.blob.removeMessage).toHaveBeenCalledWith('card-123', 'blob-123', 'msg-123') + expect(mockClient.removeMessageMeta).toHaveBeenCalledWith('card-123', 'msg-123') + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if message not found', async () => { + const event: Enriched = { + type: MessageEventType.RemovePatch, + cardId: 'card-123', + messageId: 'msg-123', + socialId, + date: new Date(), + _eventExtra: {} + } + + mockClient.getMessageMeta.mockResolvedValue(undefined) + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + }) + + describe('event - ReactionPatch', () => { + it('should add reaction to message', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId: 'card-123', + messageId: 'msg-123', + operation: { + opcode: 'add', + reaction: '👍' + }, + personUuid: 'person-123', + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.addReaction).toHaveBeenCalledWith( + 'card-123', + 'blob-123', + 'msg-123', + '👍', + 'person-123', + event.date + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should remove reaction from message', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId: 'card-123', + messageId: 'msg-123', + operation: { + opcode: 'remove', + reaction: '👍' + }, + personUuid: 'person-123', + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.removeReaction).toHaveBeenCalledWith('card-123', 'blob-123', 'msg-123', '👍', 'person-123') + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if personUuid is missing', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId: 'card-123', + messageId: 'msg-123', + operation: { + opcode: 'add', + reaction: '👍' + }, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + }) + + describe('event - AttachmentPatch', () => { + it('should add attachments to message', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123', + messageId: 'msg-123', + operations: [ + { + opcode: 'add', + attachments: [ + { + id: 'att-1', + mimeType: 'image/png', + params: { fileName: 'test.png' } + } + ] + } + ], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.addAttachments).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should remove attachments from message', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123', + messageId: 'msg-123', + operations: [ + { + opcode: 'remove', + ids: ['att-1', 'att-2'] + } + ], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.removeAttachments).toHaveBeenCalledWith('card-123', 'blob-123', 'msg-123', ['att-1', 'att-2']) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should set attachments on message', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123', + messageId: 'msg-123', + operations: [ + { + opcode: 'set', + attachments: [ + { + id: 'att-1', + mimeType: 'image/png', + params: { fileName: 'test.png' } + } + ] + } + ], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.setAttachments).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should update attachments on message', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123', + messageId: 'msg-123', + operations: [ + { + opcode: 'update', + attachments: [ + { + id: 'att-1', + params: { fileName: 'updated.png' } + } + ] + } + ], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.updateAttachments).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - BlobPatch (deprecated)', () => { + it('should convert blob attach to attachment add', async () => { + const event: Enriched = { + type: MessageEventType.BlobPatch, + cardId: 'card-123', + messageId: 'msg-123', + operations: [ + { + opcode: 'attach', + blobs: [ + { + blobId: 'blob-1', + mimeType: 'image/png', + fileName: 'test.png', + size: 1024 + } + ] + } + ], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.addAttachments).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should convert blob detach to attachment remove', async () => { + const event: Enriched = { + type: MessageEventType.BlobPatch, + cardId: 'card-123', + messageId: 'msg-123', + operations: [ + { + opcode: 'detach', + blobIds: ['blob-1', 'blob-2'] + } + ], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.removeAttachments).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - ThreadPatch', () => { + it('should attach thread to message', async () => { + const event: Enriched = { + type: MessageEventType.ThreadPatch, + cardId: 'card-123', + messageId: 'msg-123', + operation: { + opcode: 'attach', + threadId: 'thread-123', + threadType: 'task' as CardType + }, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.attachThreadMeta).toHaveBeenCalledWith( + 'card-123', + 'msg-123', + 'thread-123', + 'task', + socialId, + event.date + ) + expect(mockClient.blob.attachThread).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should update thread on message', async () => { + const event: Enriched = { + type: MessageEventType.ThreadPatch, + cardId: 'card-123', + messageId: 'msg-123', + operation: { + opcode: 'update', + threadId: 'thread-123', + update: { repliesCount: 5 } + }, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.updateThread).toHaveBeenCalledWith( + 'card-123', + 'blob-123', + 'msg-123', + 'thread-123', + { repliesCount: 5 } + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should add thread reply', async () => { + const event: Enriched = { + type: MessageEventType.ThreadPatch, + cardId: 'card-123', + messageId: 'msg-123', + operation: { + opcode: 'addReply', + threadId: 'thread-123' + }, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.findPersonUuid).toHaveBeenCalled() + expect(mockClient.blob.addThreadReply).toHaveBeenCalledWith( + 'card-123', + 'blob-123', + 'msg-123', + 'thread-123', + 'person-123', + event.date + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should remove thread reply', async () => { + const event: Enriched = { + type: MessageEventType.ThreadPatch, + cardId: 'card-123', + messageId: 'msg-123', + operation: { + opcode: 'removeReply', + threadId: 'thread-123' + }, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.findPersonUuid).toHaveBeenCalled() + expect(mockClient.blob.removeThreadReply).toHaveBeenCalledWith( + 'card-123', + 'blob-123', + 'msg-123', + 'thread-123', + 'person-123' + ) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - Notification events', () => { + it('should create notification', async () => { + const event: Enriched = { + type: NotificationEventType.CreateNotification, + contextId: 'ctx-123', + messageId: 'msg-123', + blobId: 'blob-123', + notificationType: 'message', + read: false, + content: 'Test notification', + creator: socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.createNotification).toHaveBeenCalledWith( + 'ctx-123', + 'msg-123', + 'blob-123', + 'message', + false, + 'Test notification', + socialId, + event.date + ) + expect(event.notificationId).toBe('notif-123') + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should update notification', async () => { + const event: Enriched = { + type: NotificationEventType.UpdateNotification, + contextId: 'ctx-123', + account: accountUuid, + query: { type: 'message' }, + updates: { read: true }, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.updateNotification).toHaveBeenCalledWith( + { + contextId: 'ctx-123', + account: accountUuid, + type: 'message' + }, + { read: true } + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if no notifications updated', async () => { + const event: Enriched = { + type: NotificationEventType.UpdateNotification, + contextId: 'ctx-123', + account: accountUuid, + query: {}, + updates: { read: true }, + date: new Date(), + _eventExtra: {} + } + + mockClient.db.updateNotification.mockResolvedValue(0) + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + + it('should remove notifications', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveNotifications, + contextId: 'ctx-123', + account: accountUuid, + ids: ['notif-1', 'notif-2'], + date: new Date(), + _eventExtra: {} + } + + mockClient.db.removeNotifications.mockResolvedValue(['notif-1', 'notif-2']) + + await middleware.event(session, event, false) + + expect(mockClient.db.removeNotifications).toHaveBeenCalledWith({ + contextId: 'ctx-123', + account: accountUuid, + id: ['notif-1', 'notif-2'] + }) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if no ids to remove', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveNotifications, + contextId: 'ctx-123', + account: accountUuid, + ids: [], + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + + it('should create notification context', async () => { + const event: Enriched = { + type: NotificationEventType.CreateNotificationContext, + account: accountUuid, + cardId: 'card-123', + lastUpdate: new Date(), + lastView: new Date(), + lastNotify: new Date(), + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.createNotificationContext).toHaveBeenCalled() + expect(event.contextId).toBe('ctx-123') + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should remove notification context', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveNotificationContext, + contextId: 'ctx-123', + account: accountUuid, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.removeContext).toHaveBeenCalledWith({ + id: 'ctx-123', + account: accountUuid + }) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if context not found', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveNotificationContext, + contextId: 'ctx-123', + account: accountUuid, + date: new Date(), + _eventExtra: {} + } + + mockClient.db.removeContext.mockResolvedValue(undefined) + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + + it('should update notification context', async () => { + const event: Enriched = { + type: NotificationEventType.UpdateNotificationContext, + contextId: 'ctx-123', + account: accountUuid, + updates: { lastView: new Date() }, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.updateContext).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - Collaborator events', () => { + it('should add collaborators', async () => { + const event: Enriched = { + type: NotificationEventType.AddCollaborators, + cardId: 'card-123', + cardType: 'task' as CardType, + collaborators: [accountUuid, 'account-456' as AccountUuid], + socialId, + date: new Date(), + _eventExtra: {} + } + + mockClient.db.addCollaborators.mockResolvedValue([accountUuid, 'account-456' as AccountUuid]) + + await middleware.event(session, event, false) + + expect(mockClient.db.addCollaborators).toHaveBeenCalledWith( + 'card-123', + 'task', + [accountUuid, 'account-456'], + event.date + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if no collaborators added', async () => { + const event: Enriched = { + type: NotificationEventType.AddCollaborators, + cardId: 'card-123', + cardType: 'task' as CardType, + collaborators: [accountUuid], + socialId, + date: new Date(), + _eventExtra: {} + } + + mockClient.db.addCollaborators.mockResolvedValue([]) + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + + it('should remove collaborators', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveCollaborators, + cardId: 'card-123', + cardType: 'task' as CardType, + collaborators: [accountUuid], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.removeCollaborators).toHaveBeenCalledWith({ + cardId: 'card-123', + account: [accountUuid] + }) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if no collaborators to remove', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveCollaborators, + cardId: 'card-123', + cardType: 'task' as CardType, + collaborators: [], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + }) + + describe('event - Label events', () => { + it('should create label', async () => { + const event: Enriched = { + type: LabelEventType.CreateLabel, + cardId: 'card-123', + cardType: 'task' as CardType, + labelId: 'label-123', + account: accountUuid, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.createLabel).toHaveBeenCalledWith( + 'card-123', + 'task', + 'label-123', + accountUuid, + event.date + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should remove label', async () => { + const event: Enriched = { + type: LabelEventType.RemoveLabel, + labelId: 'label-123', + cardId: 'card-123', + account: accountUuid, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.removeLabels).toHaveBeenCalledWith({ + labelId: 'label-123', + cardId: 'card-123', + account: accountUuid + }) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - Peer events', () => { + it('should create peer', async () => { + const event: Enriched = { + type: PeerEventType.CreatePeer, + workspaceId: workspace, + cardId: 'card-123', + kind: 'slack', + value: 'channel-123', + extra: { foo: 'bar' }, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.createPeer).toHaveBeenCalledWith( + workspace, + 'card-123', + 'slack', + 'channel-123', + { foo: 'bar' }, + event.date + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should remove peer', async () => { + const event: Enriched = { + type: PeerEventType.RemovePeer, + workspaceId: workspace, + cardId: 'card-123', + kind: 'slack', + value: 'channel-123', + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.removePeer).toHaveBeenCalledWith( + workspace, + 'card-123', + 'slack', + 'channel-123' + ) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - Card events', () => { + it('should handle UpdateCardType event', async () => { + const event: Enriched = { + type: CardEventType.UpdateCardType, + cardId: 'card-123', + cardType: 'issue' as CardType, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should handle RemoveCard event', async () => { + const event: Enriched = { + type: CardEventType.RemoveCard, + cardId: 'card-123', + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('close', () => { + it('should close database connection', () => { + middleware.close() + + expect(mockClient.db.close).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/server/src/__tests__/middleware/triggers.test.ts b/packages/server/src/__tests__/middleware/triggers.test.ts new file mode 100644 index 0000000..a16efe8 --- /dev/null +++ b/packages/server/src/__tests__/middleware/triggers.test.ts @@ -0,0 +1,891 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type MeasureContext, type WorkspaceUuid } from '@hcengineering/core' +import { + MessageEventType, + type SessionData, + NotificationEventType +} from '@hcengineering/communication-sdk-types' +import { type AccountUuid, type CardType, type Markdown, type SocialID } from '@hcengineering/communication-types' + +import { TriggersMiddleware } from '../../middleware/triggers' +import { type Enriched, type MiddlewareContext, type CommunicationCallbacks, Middleware } from '../../types' +import { type LowLevelClient } from '../../client' +import { notify } from '../../notification/notification' + +// Mock external dependencies +jest.mock('../../triggers/all', () => []) +jest.mock('../../notification/notification', () => ({ + notify: jest.fn().mockResolvedValue([]) +})) + +describe('TriggersMiddleware', () => { + // Test fixtures + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: any + let mockCallbacks: jest.Mocked + let session: SessionData + let middleware: TriggersMiddleware + + // Test constants + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + beforeEach(() => { + jest.clearAllMocks() + + // Setup mock context + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis(), + contextData: undefined + } as any as jest.Mocked + + // Setup mock client + mockClient = { + db: { + findNotificationContexts: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findThreadMeta: jest.fn().mockResolvedValue([]), + getAccountsByPersonIds: jest.fn().mockResolvedValue([]) + }, + blob: {}, + findPersonUuid: jest.fn().mockResolvedValue('person-123') + } as unknown as jest.Mocked + + // Setup mock middleware chain + mockNext = { + event: jest.fn().mockResolvedValue({}), + handleBroadcast: jest.fn(), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + // Setup mock callbacks + mockCallbacks = { + registerAsyncRequest: jest.fn((ctx, fn) => { + void fn(ctx).catch(() => {}) + return undefined + }), + broadcast: jest.fn(), + enqueue: jest.fn() + } as any as jest.Mocked + + // Setup middleware context + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set(), + head: mockNext + } + + // Setup test session + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123', + asyncData: [] + } as any as SessionData + + middleware = new TriggersMiddleware(mockCallbacks, mockContext, mockNext) + }) + + describe('event', () => { + it('should process event and call next middleware', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test message' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + const result = await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + expect(result).toBeDefined() + }) + + it('should skip propagation if event has skipPropagate flag', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + skipPropagate: true, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should handle derived events', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123', + messageId: 'msg-123', + content: 'Updated' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, true) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + + it('should register async request for non-derived events', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + session.contextData = { foo: 'bar' } + + await middleware.event(session, event, false) + + expect(mockCallbacks.registerAsyncRequest).toHaveBeenCalled() + expect(session.isAsyncContext).toBe(true) + }) + + it('should not register duplicate async requests', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + session.contextData = { foo: 'bar' } + session.isAsyncContext = true + + await middleware.event(session, event, false) + + expect(mockCallbacks.registerAsyncRequest).not.toHaveBeenCalled() + }) + + it('should handle events without context data', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockCallbacks.registerAsyncRequest).not.toHaveBeenCalled() + }) + + it('should sort async data by date', async () => { + const date1 = new Date('2025-01-01T10:00:00Z') + const date2 = new Date('2025-01-01T09:00:00Z') + const date3 = new Date('2025-01-01T11:00:00Z') + + const event1: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-1', + date: date1, + _eventExtra: {} + } + + const event2: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-2', + date: date2, + _eventExtra: {} + } + + const event3: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-3', + date: date3, + _eventExtra: {} + } + + session.asyncData = [event1, event2, event3] + + await middleware.event(session, event1, false) + + expect(session.asyncData).toBeDefined() + }) + }) + + describe('processDerived', () => { + it('should process derived events', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.processDerived(session, [event], true) + + expect(mockCallbacks.registerAsyncRequest).not.toHaveBeenCalled() + }) + + it('should register async request for non-derived events with context data', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.contextData = { foo: 'bar' } + + await middleware.processDerived(session, [event], false) + + expect(mockCallbacks.registerAsyncRequest).toHaveBeenCalled() + expect(session.isAsyncContext).toBe(true) + }) + + it('should handle multiple events', async () => { + const event1: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-1', + date: new Date(), + _eventExtra: {} + } + + const event2: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123', + messageId: 'msg-2', + content: 'Updated' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.processDerived(session, [event1, event2], false) + + expect(session.asyncData).toBeDefined() + }) + + it('should filter out events with skipPropagate in asyncData', async () => { + const event1: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as any, + messageId: 'msg-1', + date: new Date(), + skipPropagate: false, + _eventExtra: {} + } + + const event2: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-456' as any, + messageId: 'msg-2', + date: new Date(), + skipPropagate: true, + _eventExtra: {} + } + + await middleware.processDerived(session, [event1, event2], false) + + expect(session.asyncData).toBeDefined() + }) + }) + + describe('notification events', () => { + it('should handle notification context events', async () => { + const event: Enriched = { + type: NotificationEventType.CreateNotificationContext, + account: accountUuid, + cardId: 'card-123', + lastUpdate: new Date(), + lastView: new Date(), + lastNotify: new Date(), + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should handle update notification events', async () => { + const event: Enriched = { + type: NotificationEventType.UpdateNotification, + contextId: 'ctx-123', + account: accountUuid, + query: { type: 'message' }, + updates: { read: true }, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('async context handling', () => { + it('should handle async context correctly', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + session.isAsyncContext = true + + await middleware.event(session, event, false) + + expect(mockCallbacks.registerAsyncRequest).not.toHaveBeenCalled() + }) + + it('should clear asyncData after processing non-async events', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.asyncData = [ + { + type: MessageEventType.CreateMessage, + cardId: 'card-456' as any, + messageId: 'msg-456' as any, + cardType: 'task' as CardType, + messageType: 'text' as any, + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + ] + + await middleware.event(session, event, false) + + expect(session.asyncData).toEqual([]) + }) + + it('should preserve asyncData in async context', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.isAsyncContext = true + session.asyncData = [ + { + type: MessageEventType.CreateMessage, + cardId: 'card-456' as any, + messageId: 'msg-456' as any, + cardType: 'task' as CardType, + messageType: 'text' as any, + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + ] + + const initialAsyncDataLength = session.asyncData.length + + await middleware.event(session, event, false) + + expect(session.asyncData.length).toBeGreaterThanOrEqual(initialAsyncDataLength) + }) + }) + + describe('error handling', () => { + it('should handle errors in trigger execution', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + mockNext.event.mockRejectedValue(new Error('Trigger error')) + + await expect(middleware.event(session, event, false)).rejects.toThrow('Trigger error') + }) + + it('should handle errors in async context', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.contextData = { foo: 'bar' } + + let asyncCallbackPromise: Promise | undefined + mockCallbacks.registerAsyncRequest.mockImplementation((ctx, fn) => { + asyncCallbackPromise = fn(ctx) + return undefined + }) + + const notifyMock = notify as jest.MockedFunction + notifyMock.mockRejectedValueOnce(new Error('Async error')) + + await middleware.event(session, event, false) + + expect(mockCallbacks.registerAsyncRequest).toHaveBeenCalled() + + if (asyncCallbackPromise !== undefined) { + await expect(asyncCallbackPromise).rejects.toThrow('Async error') + } + }) + }) + + describe('edge cases', () => { + it('should handle empty asyncData array', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.asyncData = [] + + await middleware.event(session, event, false) + + expect(session.asyncData).toEqual([]) + }) + + it('should handle undefined asyncData', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.asyncData = undefined as any + + await middleware.event(session, event, false) + + expect(session.asyncData).toBeDefined() + }) + + it('should handle events without head middleware', async () => { + const contextWithoutHead = { ...mockContext, head: undefined } + const middlewareWithoutHead = new TriggersMiddleware(mockCallbacks, contextWithoutHead, mockNext) + + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + await middlewareWithoutHead.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should handle events with null values', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123', + messageId: 'msg-123', + content: null as any, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should process events with skipPropagate', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + skipPropagate: true, + date: new Date(), + _eventExtra: {} + } + + const result = await middleware.event(session, event, false) + + expect(result).toBeDefined() + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should handle very large asyncData arrays', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.asyncData = Array.from({ length: 1000 }, (_, i) => ({ + type: MessageEventType.CreateMessage, + cardId: `card-${i}`, + messageId: `msg-${i}`, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + })) as any + + await middleware.event(session, event, false) + + expect(session.asyncData).toBeDefined() + }) + + it('should handle events with different date formats', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date('2025-01-01T00:00:00.000Z'), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should handle concurrent event processing', async () => { + const event1: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-1', + messageId: 'msg-1', + date: new Date(), + _eventExtra: {} + } + + const event2: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-2', + messageId: 'msg-2', + date: new Date(), + _eventExtra: {} + } + + await Promise.all([ + middleware.event(session, event1, false), + middleware.event(session, event2, false) + ]) + + expect(mockNext.event).toHaveBeenCalledTimes(2) + }) + + it('should preserve event order in asyncData', async () => { + const dates = [ + new Date('2025-01-01T10:00:00Z'), + new Date('2025-01-01T09:00:00Z'), + new Date('2025-01-01T11:00:00Z') + ] + + const events = dates.map((date, i) => ({ + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: `msg-${i}`, + date, + _eventExtra: {} + })) + + for (const event of events) { + await middleware.event(session, event as Enriched, false) + } + + expect(session.asyncData).toBeDefined() + }) + }) + + describe('trigger integration', () => { + it('should execute triggers for message events', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test with @mention' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should execute triggers for notification events', async () => { + const event: Enriched = { + type: NotificationEventType.CreateNotification, + contextId: 'ctx-123', + messageId: 'msg-123', + blobId: 'blob-123', + notificationType: 'message', + content: 'Test notification', + creator: socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should handle multiple trigger results', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test message' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('async request management', () => { + it('should register only one async request per session', async () => { + const event1: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-1', + messageId: 'msg-1', + date: new Date(), + _eventExtra: {} + } + + const event2: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-2', + messageId: 'msg-2', + date: new Date(), + _eventExtra: {} + } + + session.contextData = { foo: 'bar' } + + await middleware.event(session, event1, false) + await middleware.event(session, event2, false) + + expect(mockCallbacks.registerAsyncRequest).toHaveBeenCalledTimes(1) + }) + + it('should handle async request with contextData', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.contextData = { userId: 'user-123', requestId: 'req-456' } + + await middleware.event(session, event, false) + + expect(mockCallbacks.registerAsyncRequest).toHaveBeenCalled() + expect(session.isAsyncContext).toBe(true) + }) + + it('should execute async callback immediately in tests', async () => { + let callbackExecuted = false + mockCallbacks.registerAsyncRequest.mockImplementation((ctx, fn) => { + callbackExecuted = true + void fn(ctx) + return undefined + }) + + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.contextData = { foo: 'bar' } + + await middleware.event(session, event, false) + + expect(callbackExecuted).toBe(true) + }) + }) + + describe('broadcast handling', () => { + it('should call handleBroadcast after event processing', async () => { + const handleBroadcastSpy = jest.spyOn(middleware, 'handleBroadcast') + + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(handleBroadcastSpy).toHaveBeenCalled() + }) + + it('should handle broadcast with multiple events', async () => { + const events: Enriched[] = [ + { + type: MessageEventType.CreateMessage, + cardId: 'card-1', + messageId: 'msg-1', + date: new Date(), + _eventExtra: {} + }, + { + type: MessageEventType.CreateMessage, + cardId: 'card-2', + messageId: 'msg-2', + date: new Date(), + _eventExtra: {} + } + ] + + middleware.handleBroadcast(session, events) + + expect(mockNext.handleBroadcast).toHaveBeenCalledWith(session, events) + }) + + it('should handle empty broadcast', async () => { + middleware.handleBroadcast(session, []) + + expect(mockNext.handleBroadcast).toHaveBeenCalledWith(session, []) + }) + }) + + describe('performance and memory', () => { + it('should handle rapid succession of events', async () => { + const events = Array.from({ length: 100 }, (_, i) => ({ + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: `msg-${i}`, + date: new Date(), + _eventExtra: {} + })) + + for (const event of events) { + await middleware.event(session, event as Enriched, false) + } + + expect(mockNext.event).toHaveBeenCalledTimes(100) + }) + + it('should clean up processed peers events', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(session.asyncData).toEqual([]) + }) + }) +}) diff --git a/packages/server/src/__tests__/middleware/validate.test.ts b/packages/server/src/__tests__/middleware/validate.test.ts new file mode 100644 index 0000000..6d4276f --- /dev/null +++ b/packages/server/src/__tests__/middleware/validate.test.ts @@ -0,0 +1,1027 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, WorkspaceUuid } from '@hcengineering/core' +import { MessageEventType, NotificationEventType, SessionData } from '@hcengineering/communication-sdk-types' +import { AccountUuid, CardID, CardType, Markdown, SocialID } from '@hcengineering/communication-types' + +import { ValidateMiddleware } from '../../middleware/validate' +import { Enriched, Middleware, MiddlewareContext } from '../../types' +import { LowLevelClient } from '../../client' + +describe('ValidateMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: any + let session: SessionData + let middleware: ValidateMiddleware + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + mockClient = { + db: {}, + blob: {} + } as unknown as jest.Mocked + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123' + } as any as SessionData + + middleware = new ValidateMiddleware(mockContext, mockNext) + }) + + describe('event - CreateMessage validation', () => { + it('should validate correct CreateMessage event', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test message' as Markdown, + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should reject CreateMessage without cardId', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + + it('should reject CreateMessage without content', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + messageType: 'text', + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + + it('should reject CreateMessage without socialId', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + }) + + describe('event - UpdatePatch validation', () => { + it('should validate correct UpdatePatch event', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123', + content: 'Updated' as Markdown, + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should validate UpdatePatch without messageId', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123' as CardID, + content: 'Updated' as Markdown, + socialId, + date: new Date() + } + + // UpdatePatch can have optional messageId according to schema + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - RemovePatch validation', () => { + it('should validate correct RemovePatch event', async () => { + const event: Enriched = { + type: MessageEventType.RemovePatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123', + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject RemovePatch without cardId', async () => { + const event: Enriched = { + type: MessageEventType.RemovePatch, + messageId: 'msg-123', + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + }) + + describe('event - ReactionPatch validation', () => { + it('should validate correct ReactionPatch add event', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123', + operation: { + opcode: 'add', + reaction: '👍' + }, + personUuid: 'person-123', + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate correct ReactionPatch remove event', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123', + operation: { + opcode: 'remove', + reaction: '👍' + }, + personUuid: 'person-123', + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject ReactionPatch without operation', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + personUuid: 'person-123', + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + + it('should reject ReactionPatch with invalid opcode', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operation: { + opcode: 'invalid', + reaction: '👍' + }, + personUuid: 'person-123', + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + }) + + describe('event - AttachmentPatch validation', () => { + it('should validate correct AttachmentPatch add event', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'add', + attachments: [ + { + id: '550e8400-e29b-41d4-a716-446655440000', + mimeType: 'application/blob', + params: { + blobId: '550e8400-e29b-41d4-a716-446655440001', + mimeType: 'image/png', + fileName: 'test.png', + size: 1024 + } + } + ] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate AttachmentPatch remove event', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'remove', + ids: ['550e8400-e29b-41d4-a716-446655440000'] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate AttachmentPatch set event', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'set', + attachments: [ + { + id: '550e8400-e29b-41d4-a716-446655440000', + mimeType: 'application/blob', + params: { + blobId: '550e8400-e29b-41d4-a716-446655440002', + mimeType: 'image/png', + fileName: 'file.png', + size: 2048 + } + } + ] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate AttachmentPatch update event', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'update', + attachments: [ + { + id: '550e8400-e29b-41d4-a716-446655440000', + params: { newData: 'value' } + } + ] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject AttachmentPatch with empty operations', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [], + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + + it('should reject AttachmentPatch without operations', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + + it('should validate AttachmentPatch with link preview', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'add', + attachments: [ + { + id: '550e8400-e29b-41d4-a716-446655440000', + mimeType: 'application/vnd.huly.link-preview', + params: { + url: 'https://example.com', + host: 'example.com' + } + } + ] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - ThreadPatch validation', () => { + it('should validate correct ThreadPatch attach event', async () => { + const event: Enriched = { + type: MessageEventType.ThreadPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operation: { + opcode: 'attach', + threadId: 'thread-123', + threadType: 'task' as CardType + }, + personUuid: 'person-123', + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject ThreadPatch without operation', async () => { + const event: Enriched = { + type: MessageEventType.ThreadPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + personUuid: 'person-123', + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + + it('should reject ThreadPatch without personUuid', async () => { + const event: Enriched = { + type: MessageEventType.ThreadPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operation: { + opcode: 'attach', + threadId: 'thread-123', + threadType: 'task' as CardType + }, + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + }) + + describe('event - BlobPatch validation (deprecated)', () => { + it('should validate correct BlobPatch attach event', async () => { + const event: Enriched = { + type: MessageEventType.BlobPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'attach', + blobs: [ + { + blobId: '550e8400-e29b-41d4-a716-446655440000', + mimeType: 'image/png', + fileName: 'test.png', + size: 1024 + } + ] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate BlobPatch detach event', async () => { + const event: Enriched = { + type: MessageEventType.BlobPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'detach', + blobIds: ['550e8400-e29b-41d4-a716-446655440000'] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate BlobPatch set event', async () => { + const event: Enriched = { + type: MessageEventType.BlobPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'set', + blobs: [ + { + blobId: '550e8400-e29b-41d4-a716-446655440000', + mimeType: 'application/pdf', + fileName: 'doc.pdf', + size: 2048 + } + ] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate BlobPatch update event', async () => { + const event: Enriched = { + type: MessageEventType.BlobPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'update', + blobs: [ + { + blobId: '550e8400-e29b-41d4-a716-446655440000', + fileName: 'updated.png' + } + ] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - Notification events validation', () => { + it('should validate UpdateNotification event', async () => { + const event: Enriched = { + type: NotificationEventType.UpdateNotification, + contextId: 'ctx-123' as any, + account: accountUuid, + query: { + type: 'message' + }, + updates: { + read: true + }, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate RemoveNotificationContext event', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveNotificationContext, + contextId: 'ctx-123' as any, + account: accountUuid, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate UpdateNotificationContext event', async () => { + const event: Enriched = { + type: NotificationEventType.UpdateNotificationContext, + contextId: 'ctx-123' as any, + account: accountUuid, + updates: { + lastView: new Date() + }, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate AddCollaborators event', async () => { + const event: Enriched = { + type: NotificationEventType.AddCollaborators, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + collaborators: [accountUuid, 'account-456' as AccountUuid], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject AddCollaborators with empty collaborators array', async () => { + const event: Enriched = { + type: NotificationEventType.AddCollaborators, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + collaborators: [], + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + + it('should validate RemoveCollaborators event', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveCollaborators, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + collaborators: [accountUuid], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject RemoveCollaborators with empty collaborators array', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveCollaborators, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + collaborators: [], + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + }) + + describe('event - CreateMessage with options', () => { + it('should validate CreateMessage with skipLinkPreviews option', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + options: { + skipLinkPreviews: true + } + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate CreateMessage with noNotify option', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + options: { + noNotify: true + } + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate CreateMessage with ignoreMentions option', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test @user' as Markdown, + socialId, + date: new Date(), + options: { + ignoreMentions: true + } + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate CreateMessage with language', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + language: 'en' + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate CreateMessage with extra data', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + extra: { + customField: 'value' + } + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - UpdatePatch with options', () => { + it('should validate UpdatePatch with skipLinkPreviewsUpdate option', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + content: 'Updated' as Markdown, + socialId, + date: new Date(), + options: { + skipLinkPreviewsUpdate: true + } + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate UpdatePatch with extra data', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + content: 'Updated' as Markdown, + socialId, + date: new Date(), + extra: { + customField: 'newValue' + } + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - derived events', () => { + it('should skip validation for derived events', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + // Missing required fields + date: new Date() + } + + await middleware.event(session, event, true) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + }) + + describe('findNotificationContexts', () => { + it('should validate correct params', async () => { + const params = { cardId: 'card-123' as any } + + await middleware.findNotificationContexts(session, params) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith(session, params, undefined) + }) + + it('should validate params with multiple fields', async () => { + const params = { + cardId: 'card-123' as any, + account: accountUuid, + limit: 10 + } + + await middleware.findNotificationContexts(session, params) + + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + + it('should pass subscription parameter', async () => { + const params = { cardId: 'card-123' as any } + const subscription = 'sub-123' + + await middleware.findNotificationContexts(session, params, subscription) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith(session, params, subscription) + }) + + it('should validate params with array of cardIds', async () => { + const params = { + cardId: ['card-1', 'card-2', 'card-3'] as any[] + } + + await middleware.findNotificationContexts(session, params) + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + + it('should validate params with array of accounts', async () => { + const params = { + cardId: 'card-123' as any, + account: [accountUuid, 'account-456' as AccountUuid] + } + + await middleware.findNotificationContexts(session, params) + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + + it('should validate params with notifications nested params', async () => { + const params = { + cardId: 'card-123' as any, + notifications: { + limit: 10, + order: 1, + read: false, + total: true + } + } + + await middleware.findNotificationContexts(session, params) + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + + it('should validate params with order parameter', async () => { + const params = { + cardId: 'card-123' as any, + order: 1, + limit: 20 + } + + await middleware.findNotificationContexts(session, params) + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + }) + + describe('findNotifications', () => { + it('should validate correct params', async () => { + const params = { contextId: 'ctx-123' as any } + + await middleware.findNotifications(session, params) + + expect(mockNext.findNotifications).toHaveBeenCalledWith(session, params, undefined) + }) + + it('should validate params with query', async () => { + const params = { + contextId: 'ctx-123' as any, + read: false, + limit: 20 + } + + await middleware.findNotifications(session, params) + + expect(mockNext.findNotifications).toHaveBeenCalled() + }) + + it('should validate params with all optional fields', async () => { + const params = { + contextId: 'ctx-123' as any, + read: false, + cardId: 'card-123' as any, + total: true, + limit: 50, + order: 1 + } + + await middleware.findNotifications(session, params) + expect(mockNext.findNotifications).toHaveBeenCalled() + }) + + it('should validate params without contextId', async () => { + const params = { + account: accountUuid, + cardId: 'card-123' as any + } + + await middleware.findNotifications(session, params) + expect(mockNext.findNotifications).toHaveBeenCalled() + }) + }) + + describe('findLabels', () => { + it('should validate correct params', async () => { + const params = { cardId: 'card-123' as any } + + await middleware.findLabels(session, params) + + expect(mockNext.findLabels).toHaveBeenCalledWith(session, params, undefined) + }) + + it('should validate params with labelId', async () => { + const params = { + cardId: 'card-123' as any, + labelId: 'label-456' as any + } + + await middleware.findLabels(session, params) + + expect(mockNext.findLabels).toHaveBeenCalled() + }) + + it('should validate params with array of labelIds', async () => { + const params = { + cardId: 'card-123' as any, + labelId: ['label-1', 'label-2'] as any + } + + await middleware.findLabels(session, params) + expect(mockNext.findLabels).toHaveBeenCalled() + }) + + it('should validate params with cardType', async () => { + const params = { + cardId: 'card-123' as any, + cardType: 'task' as CardType + } + + await middleware.findLabels(session, params) + expect(mockNext.findLabels).toHaveBeenCalled() + }) + + it('should validate params with array of cardTypes', async () => { + const params = { + cardId: 'card-123' as any, + cardType: ['task', 'issue'] as CardType[] + } + + await middleware.findLabels(session, params) + expect(mockNext.findLabels).toHaveBeenCalled() + }) + }) + + describe('findCollaborators', () => { + it('should validate correct params', async () => { + const params = { cardId: 'card-123' as CardID } + + await middleware.findCollaborators(session, params) + + expect(mockNext.findCollaborators).toHaveBeenCalledWith(session, params) + }) + }) + + describe('findMessagesGroups', () => { + it('should validate correct params', async () => { + const params = { cardId: 'card-123' as CardID } + + await middleware.findMessagesGroups(session, params) + + expect(mockNext.findMessagesGroups).toHaveBeenCalledWith(session, params) + }) + }) + + describe('validation error handling', () => { + it('should log validation errors', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + // Missing required fields + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + + expect(mockMeasureCtx.error).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/server/src/__tests__/middlewares.test.ts b/packages/server/src/__tests__/middlewares.test.ts new file mode 100644 index 0000000..eab78e1 --- /dev/null +++ b/packages/server/src/__tests__/middlewares.test.ts @@ -0,0 +1,644 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, WorkspaceUuid } from '@hcengineering/core' +import { MessageEventType, SessionData } from '@hcengineering/communication-sdk-types' +import { AccountUuid, CardType, Markdown, SocialID } from '@hcengineering/communication-types' +import { buildMiddlewares, Middlewares } from '../middlewares' +import { Metadata, MiddlewareContext, MiddlewareCreateFn, CommunicationCallbacks } from '../types' +import { LowLevelClient } from '../client' + +describe('Middlewares', () => { + let mockContext: MiddlewareContext + let mockClient: { + db: { + findPeers: jest.Mock + findMessagesMeta: jest.Mock + findNotificationContexts: jest.Mock + findNotifications: jest.Mock + findLabels: jest.Mock + findCollaborators: jest.Mock + } + blob: Record + } + let mockMeasureCtx: jest.Mocked + let mockCallbacks: jest.Mocked + let session: SessionData + let metadata: Metadata + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis(), + contextData: undefined + } as unknown as jest.Mocked + + mockClient = { + db: { + findPeers: jest.fn().mockResolvedValue([]), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]) + }, + blob: {} + } + + mockCallbacks = { + registerAsyncRequest: jest.fn(), + broadcast: jest.fn(), + enqueue: jest.fn() + } as any as jest.Mocked + + metadata = { + accountsUrl: 'http://accounts.test', + hulylakeUrl: 'http://hulylake.test', + secret: 'test-secret', + messagesPerBlob: 100 + } + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient as unknown as LowLevelClient, + workspace, + metadata, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123', + asyncData: [] + } as unknown as SessionData + }) + + describe('buildMiddlewares', () => { + it('should build middleware chain with all middlewares', async () => { + const middlewares = await buildMiddlewares( + mockMeasureCtx, + workspace, + metadata, + mockClient as unknown as LowLevelClient, + mockCallbacks + ) + + expect(middlewares).toBeDefined() + expect(mockClient.db.findPeers).toHaveBeenCalledWith({ workspaceId: workspace }) + }) + + it('should initialize cadsWithPeers from database', async () => { + const peers = [ + { cardId: 'card-1' as any, workspaceId: workspace, kind: 'slack', value: 'channel-1' }, + { cardId: 'card-2' as any, workspaceId: workspace, kind: 'slack', value: 'channel-2' } + ] + mockClient.db.findPeers.mockResolvedValue(peers as any) + + await buildMiddlewares( + mockMeasureCtx, + workspace, + metadata, + mockClient as unknown as LowLevelClient, + mockCallbacks + ) + + expect(mockClient.db.findPeers).toHaveBeenCalledWith({ workspaceId: workspace }) + }) + + it('should handle errors during middleware initialization', async () => { + mockClient.db.findPeers.mockRejectedValue(new Error('Database error')) + + await expect( + buildMiddlewares(mockMeasureCtx, workspace, metadata, mockClient as unknown as LowLevelClient, mockCallbacks) + ).rejects.toThrow('Database error') + }) + }) + + describe('Middlewares.create', () => { + it('should create middleware chain from create functions', async () => { + const createFns: MiddlewareCreateFn[] = [ + async (context, next) => + ({ + ...next, + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(), + closeSession: jest.fn() + }) as any + ] + + const middlewares = await Middlewares.create(mockMeasureCtx, mockContext, createFns) + + expect(middlewares).toBeDefined() + }) + + it('should handle errors during chain building', async () => { + const createFns: MiddlewareCreateFn[] = [ + async () => { + throw new Error('Middleware initialization failed') + } + ] + + await expect(Middlewares.create(mockMeasureCtx, mockContext, createFns)).rejects.toThrow( + 'Middleware initialization failed' + ) + + expect(mockMeasureCtx.error).toHaveBeenCalledWith( + 'failed to initialize middlewares', + expect.objectContaining({ + workspace + }) + ) + }) + + it('should build chain in correct order', async () => { + const order: string[] = [] + const createFns: MiddlewareCreateFn[] = [ + async (context, next) => { + order.push('first') + return { + ...next, + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(), + closeSession: jest.fn() + } as any + }, + async (context, next) => { + order.push('second') + return { + ...next, + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(), + closeSession: jest.fn() + } as any + } + ] + + await Middlewares.create(mockMeasureCtx, mockContext, createFns) + + // Middlewares should be created in reverse order (last to first) + expect(order).toEqual(['second', 'first']) + }) + }) + + describe('Middlewares methods', () => { + let middlewares: Middlewares + let mockHead: any + + beforeEach(async () => { + mockHead = { + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([{ messageId: 'msg-1' }]), + findMessagesGroups: jest.fn().mockResolvedValue([{ blobId: 'blob-1' }]), + findNotificationContexts: jest.fn().mockResolvedValue([{ id: 'ctx-1' }]), + findNotifications: jest.fn().mockResolvedValue([{ id: 'notif-1' }]), + findLabels: jest.fn().mockResolvedValue([{ labelId: 'label-1' }]), + findCollaborators: jest.fn().mockResolvedValue([{ account: accountUuid }]), + findPeers: jest.fn().mockResolvedValue([{ cardId: 'card-1' }]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(), + closeSession: jest.fn() + } + + const createFns: MiddlewareCreateFn[] = [async () => mockHead] + + middlewares = await Middlewares.create(mockMeasureCtx, mockContext, createFns) + }) + + describe('findMessagesMeta', () => { + it('should delegate to head middleware', async () => { + const params = { cardId: 'card-123' as any } + + const result = await middlewares.findMessagesMeta(session, params) + + expect(mockHead.findMessagesMeta).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ messageId: 'msg-1' }]) + }) + + it('should return empty array if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const result = await middlewaresNoHead.findMessagesMeta(session, { cardId: 'card-123' as any }) + + expect(result).toEqual([]) + }) + }) + + describe('findMessagesGroups', () => { + it('should delegate to head middleware', async () => { + const params = { cardId: 'card-123' as any } + + const result = await middlewares.findMessagesGroups(session, params) + + expect(mockHead.findMessagesGroups).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ blobId: 'blob-1' }]) + }) + + it('should return empty array if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const result = await middlewaresNoHead.findMessagesGroups(session, { cardId: 'card-123' as any }) + + expect(result).toEqual([]) + }) + }) + + describe('findNotificationContexts', () => { + it('should delegate to head middleware', async () => { + const params = { cardId: 'card-123' as any } + const subscription = 'sub-123' + + const result = await middlewares.findNotificationContexts(session, params, subscription) + + expect(mockHead.findNotificationContexts).toHaveBeenCalledWith(session, params, subscription) + expect(result).toEqual([{ id: 'ctx-1' }]) + }) + + it('should return empty array if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const result = await middlewaresNoHead.findNotificationContexts(session, { cardId: 'card-123' as any }) + + expect(result).toEqual([]) + }) + }) + + describe('findNotifications', () => { + it('should delegate to head middleware', async () => { + const params = { contextId: 'ctx-123' as any } + const subscription = 'sub-123' + + const result = await middlewares.findNotifications(session, params, subscription) + + expect(mockHead.findNotifications).toHaveBeenCalledWith(session, params, subscription) + expect(result).toEqual([{ id: 'notif-1' }]) + }) + + it('should return empty array if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const result = await middlewaresNoHead.findNotifications(session, { contextId: 'ctx-123' as any }) + + expect(result).toEqual([]) + }) + }) + + describe('findLabels', () => { + it('should delegate to head middleware', async () => { + const params = { cardId: 'card-123' as any } + + const result = await middlewares.findLabels(session, params) + + expect(mockHead.findLabels).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ labelId: 'label-1' }]) + }) + + it('should return empty array if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const result = await middlewaresNoHead.findLabels(session, { cardId: 'card-123' as any }) + + expect(result).toEqual([]) + }) + }) + + describe('findCollaborators', () => { + it('should delegate to head middleware', async () => { + const params = { cardId: 'card-123' as any } + + const result = await middlewares.findCollaborators(session, params) + + expect(mockHead.findCollaborators).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ account: accountUuid }]) + }) + + it('should return empty array if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const result = await middlewaresNoHead.findCollaborators(session, { cardId: 'card-123' as any }) + + expect(result).toEqual([]) + }) + }) + + describe('findPeers', () => { + it('should delegate to head middleware', async () => { + const params = { workspaceId: workspace, cardId: 'card-123' as any } + + const result = await middlewares.findPeers(session, params) + + expect(mockHead.findPeers).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ cardId: 'card-1' }]) + }) + + it('should return empty array if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const result = await middlewaresNoHead.findPeers(session, { workspaceId: workspace, cardId: 'card-123' as any }) + + expect(result).toEqual([]) + }) + }) + + describe('event', () => { + it('should process event through middleware chain', async () => { + const event: any = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date() + } + + const result = await middlewares.event(session, event) + + expect(mockHead.event).toHaveBeenCalledWith(session, event, false) + expect(mockHead.handleBroadcast).toHaveBeenCalledWith(session, [event]) + expect(result).toBeDefined() + }) + + it('should handle derived flag from session', async () => { + const event: any = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123', + messageId: 'msg-123', + content: 'Updated' as Markdown, + socialId, + date: new Date() + } + + session.derived = true + + await middlewares.event(session, event) + + expect(mockHead.event).toHaveBeenCalledWith(session, event, true) + }) + + it('should return empty object if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const event: any = { + type: MessageEventType.CreateMessage, + date: new Date() + } + + const result = await middlewaresNoHead.event(session, event) + + expect(result).toEqual({}) + }) + }) + + describe('subscribeCard', () => { + it('should delegate to head middleware', () => { + const cardId = 'card-123' as any + const subscription = 'sub-123' + + middlewares.subscribeCard(session, cardId, subscription) + + expect(mockHead.subscribeCard).toHaveBeenCalledWith(session, cardId, subscription) + }) + + it('should do nothing if no head', () => { + const middlewaresNoHead = Middlewares.create(mockMeasureCtx, mockContext, []) + + expect(() => { + void middlewaresNoHead.then((m) => { + m.subscribeCard(session, 'card-123' as any, 'sub-123') + }) + }).not.toThrow() + }) + }) + + describe('unsubscribeCard', () => { + it('should delegate to head middleware', () => { + const cardId = 'card-123' as any + const subscription = 'sub-123' + + middlewares.unsubscribeCard(session, cardId, subscription) + + expect(mockHead.unsubscribeCard).toHaveBeenCalledWith(session, cardId, subscription) + }) + + it('should do nothing if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + expect(() => { + middlewaresNoHead.unsubscribeCard(session, 'card-123' as any, 'sub-123') + }).not.toThrow() + }) + }) + + describe('closeSession', () => { + it('should delegate to head middleware', async () => { + await middlewares.closeSession('session-123') + + expect(mockHead.closeSession).toHaveBeenCalledWith('session-123') + }) + + it('should do nothing if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + await expect(middlewaresNoHead.closeSession('session-123')).resolves.not.toThrow() + }) + }) + + describe('close', () => { + it('should close all middlewares', async () => { + await middlewares.close() + + expect(mockHead.close).toHaveBeenCalled() + }) + + it('should handle errors during close', async () => { + // Create a fresh context to avoid mock clearing issues + const freshMockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis(), + contextData: undefined + } as unknown as jest.Mocked + + const freshContext = { + ...mockContext, + ctx: freshMockMeasureCtx + } + + // Create a new middlewares instance with a close function that throws + const mockMiddleware = { + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(() => { + throw new Error('Close error') + }), + closeSession: jest.fn() + } + + const createFns: MiddlewareCreateFn[] = [async () => mockMiddleware] + + const testMiddlewares = await Middlewares.create(freshMockMeasureCtx, freshContext, createFns) + + await testMiddlewares.close() + + expect(mockMiddleware.close).toHaveBeenCalled() + expect(freshMockMeasureCtx.error).toHaveBeenCalledWith( + 'Failed to close middleware', + expect.objectContaining({ + workspace + }) + ) + }) + + it('should do nothing if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + await expect(middlewaresNoHead.close()).resolves.not.toThrow() + }) + }) + }) + + describe('Edge cases', () => { + it('should handle empty middleware chain', async () => { + const middlewares = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const event: any = { + type: MessageEventType.CreateMessage, + date: new Date() + } + + const result = await middlewares.event(session, event) + expect(result).toEqual({}) + + const messages = await middlewares.findMessagesMeta(session, { cardId: 'card-123' as any }) + expect(messages).toEqual([]) + }) + + it('should handle multiple middlewares in chain', async () => { + const middleware1Called: string[] = [] + const middleware2Called: string[] = [] + + const createFns: MiddlewareCreateFn[] = [ + async (context, next) => + ({ + ...next, + event: jest.fn().mockImplementation(async (s, e, d) => { + middleware1Called.push('event') + return next?.event != null ? await next.event(s, e, d) : {} + }), + findMessagesMeta: jest.fn().mockImplementation(async (s, p) => { + middleware1Called.push('findMessagesMeta') + return next?.findMessagesMeta != null ? await next.findMessagesMeta(s, p) : [] + }), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(), + closeSession: jest.fn() + }) as any, + async (context, next) => + ({ + ...next, + event: jest.fn().mockImplementation(async (s, e, d) => { + middleware2Called.push('event') + return {} + }), + findMessagesMeta: jest.fn().mockImplementation(async () => { + middleware2Called.push('findMessagesMeta') + return [{ messageId: 'msg-1' }] + }), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(), + closeSession: jest.fn() + }) as any + ] + + const middlewares = await Middlewares.create(mockMeasureCtx, mockContext, createFns) + + await middlewares.event(session, { type: MessageEventType.CreateMessage, date: new Date() } as any) + await middlewares.findMessagesMeta(session, { cardId: 'card-123' as any }) + + expect(middleware1Called).toContain('event') + expect(middleware1Called).toContain('findMessagesMeta') + expect(middleware2Called).toContain('event') + expect(middleware2Called).toContain('findMessagesMeta') + }) + }) +}) diff --git a/packages/server/src/__tests__/notification/notification.test.ts b/packages/server/src/__tests__/notification/notification.test.ts new file mode 100644 index 0000000..cbe626c --- /dev/null +++ b/packages/server/src/__tests__/notification/notification.test.ts @@ -0,0 +1,872 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type MeasureContext, type WorkspaceUuid, readOnlyGuestAccountUuid } from '@hcengineering/core' +import { + type Event, + MessageEventType, + NotificationEventType, + type CreateNotificationContextResult +} from '@hcengineering/communication-sdk-types' +import { + type AccountUuid, + type BlobID, + type CardID, + type CardType, + type ContextID, + type Markdown, + type MessageID, + type MessageMeta, + type NotificationContext, + NotificationType, + type ReactionNotificationContent, + type SocialID +} from '@hcengineering/communication-types' + +import { notify } from '../../notification/notification' +import { type TriggerCtx, type Enriched } from '../../types' +import { getNameBySocialID } from '../../triggers/utils' + +// Mock dependencies +jest.mock('@hcengineering/text-markdown', () => ({ + markdownToMarkup: jest.fn((md) => ({ type: 'doc', content: [{ type: 'text', text: md }] })) +})) + +jest.mock('@hcengineering/text-core', () => ({ + jsonToMarkup: jest.fn((json) => json), + markupToText: jest.fn((markup) => { + if (typeof markup === 'string') return markup + if (markup?.content?.[0]?.text !== undefined) return markup.content[0].text + return 'Test message text' + }) +})) + +jest.mock('../../triggers/utils', () => ({ + getNameBySocialID: jest.fn().mockResolvedValue('John Doe') +})) + +describe('notification', () => { + let mockCtx: TriggerCtx + let mockClient: any + let mockMeasureCtx: jest.Mocked + + const workspace = 'test-workspace' as WorkspaceUuid + const cardId = 'card-123' as CardID + const messageId = 'message-123' as MessageID + const blobId = 'blob-123' as BlobID + const socialId = 'social-123' as SocialID + const accountUuid = 'account-123' as AccountUuid + const contextId = 'context-123' as ContextID + const cardType = 'card' as CardType + + beforeEach(() => { + jest.clearAllMocks() + + // Reset the getNameBySocialID mock to its default behavior + const mockGetName = getNameBySocialID as jest.MockedFunction + mockGetName.mockResolvedValue('John Doe') + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any + + mockClient = { + db: { + findNotifications: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + getCardSpaceMembers: jest.fn().mockResolvedValue([accountUuid]), + getCollaboratorsCursor: jest.fn(), + getCardTitle: jest.fn().mockResolvedValue('Test Card'), + getNameByAccount: jest.fn().mockResolvedValue('John Doe') + }, + getMessageMeta: jest.fn(), + findPersonUuid: jest.fn() + } + + mockCtx = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 200 + }, + account: { + uuid: accountUuid, + socialIds: [socialId] + } as any, + execute: jest.fn() + } as any + }) + + describe('notify', () => { + describe('CreateMessage event', () => { + it('should return empty array when noNotify option is true', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId, + messageId, + content: 'Hello' as Markdown, + socialId, + date: new Date(), + cardType, + options: { noNotify: true } + } as any + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + const getMessageMetaSpy = mockClient.getMessageMeta as jest.Mock + expect(getMessageMetaSpy).toHaveBeenCalledTimes(0) + }) + + it('should return empty array when messageId is null', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId, + messageId: null, + content: 'Hello' as Markdown, + socialId, + date: new Date(), + cardType + } as any + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + const getMessageMetaSpy = mockClient.getMessageMeta as jest.Mock + expect(getMessageMetaSpy).toHaveBeenCalledTimes(0) + }) + + it('should return empty array when message meta is not found', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId, + messageId, + content: 'Hello' as Markdown, + socialId, + date: new Date(), + cardType + } as any + + mockClient.getMessageMeta.mockResolvedValue(undefined) + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + const getMessageMetaSpy = mockClient.getMessageMeta as jest.Mock + expect(getMessageMetaSpy).toHaveBeenCalledTimes(1) + expect(getMessageMetaSpy).toHaveBeenLastCalledWith(cardId, messageId) + }) + + it('should create notification for message', async () => { + const date = new Date() + const creatorSocialId = 'creator-social' as SocialID + const creatorAccount = 'creator-account' as AccountUuid + const collaboratorAccount = 'collaborator-account' as AccountUuid + + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId, + messageId, + content: 'Hello World' as Markdown, + socialId: creatorSocialId, + date, + cardType + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: creatorSocialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + + // First call for message creator, second for collaborator + mockClient.findPersonUuid + .mockResolvedValueOnce(creatorAccount) // creator account + .mockResolvedValueOnce(creatorAccount) // when checking if it's own message + + mockClient.db.getCardSpaceMembers.mockResolvedValue([collaboratorAccount, creatorAccount]) + + // Mock collaborators cursor - return a collaborator that's not the creator + const collaborators = [{ account: collaboratorAccount, personUuid: 'person-1' as any }] + mockClient.db.getCollaboratorsCursor.mockReturnValue({ + [Symbol.asyncIterator]: async function * () { + yield collaborators + } + }) + + // Mock context doesn't exist, will be created + mockClient.db.findNotificationContexts.mockResolvedValue([]) + + // Mock context creation + const contextResult: CreateNotificationContextResult = { id: contextId } + mockCtx.execute = jest.fn().mockResolvedValue(contextResult) + + const result = await notify(mockCtx, event) + + expect(result.length).toBeGreaterThan(0) + const getMessageMetaSpy = mockClient.getMessageMeta as jest.Mock + expect(getMessageMetaSpy).toHaveBeenCalledTimes(1) + expect(getMessageMetaSpy).toHaveBeenLastCalledWith(cardId, messageId) + }) + + it('should skip collaborators not in space members', async () => { + const date = new Date() + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId, + messageId, + content: 'Hello' as Markdown, + socialId, + date, + cardType + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue('other-account' as AccountUuid) + mockClient.db.getCardSpaceMembers.mockResolvedValue([accountUuid]) + + const collaborators = [{ account: 'other-account' as AccountUuid, personUuid: 'person-2' as any }] + mockClient.db.getCollaboratorsCursor.mockReturnValue({ + [Symbol.asyncIterator]: async function * () { + yield collaborators + } + }) + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + }) + + it('should handle errors during collaborator processing', async () => { + const date = new Date() + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId, + messageId, + content: 'Hello' as Markdown, + socialId, + date, + cardType + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + + // Make sure the creator account is different from collaborators + mockClient.findPersonUuid + .mockResolvedValueOnce('creator-account' as AccountUuid) // creator account (for socialId) + .mockResolvedValueOnce('creator-account' as AccountUuid) // used for isOwn check for first collaborator + .mockResolvedValueOnce('creator-account' as AccountUuid) // used for isOwn check for second collaborator + + // Two collaborators - processing will fail for the second one + const collaborators = [ + { account: accountUuid, personUuid: 'person-1' as any }, + { account: 'account-2' as AccountUuid, personUuid: 'person-2' as any } + ] + mockClient.db.getCollaboratorsCursor.mockReturnValue({ + [Symbol.asyncIterator]: async function * () { + yield collaborators + } + }) + + mockClient.db.getCardSpaceMembers.mockResolvedValue([accountUuid, 'account-2' as AccountUuid]) + + // Return contexts for BOTH collaborators so neither needs creation + const firstContext: NotificationContext = { + id: contextId, + cardId, + account: accountUuid, + lastUpdate: date, + lastView: date, + lastNotify: date + } as any + + const secondContext: NotificationContext = { + id: 'context-456' as ContextID, + cardId, + account: 'account-2' as AccountUuid, + lastUpdate: date, + lastView: date, + lastNotify: date + } as any + + mockClient.db.findNotificationContexts.mockResolvedValue([firstContext, secondContext]) + + // Make getNameBySocialID throw an error on the second call + const mockGetName = getNameBySocialID as jest.MockedFunction + mockGetName + .mockResolvedValueOnce('John Doe') // First collaborator succeeds + .mockRejectedValueOnce(new Error('Database error')) // Second collaborator fails + + const result = await notify(mockCtx, event) + + // Should have logged the error + expect(mockMeasureCtx.error).toHaveBeenCalledWith( + 'Error on create notification', + expect.objectContaining({ + collaborator: 'account-2', + error: expect.any(Error) + }) + ) + // Result should contain events from first collaborator even though second failed + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + }) + + it('should continue processing other collaborators when one fails', async () => { + const date = new Date() + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId, + messageId, + content: 'Hello' as Markdown, + socialId, + date, + cardType + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue(accountUuid) + + const collaborators = [ + { account: accountUuid, personUuid: 'person-1' as any } + ] + mockClient.db.getCollaboratorsCursor.mockReturnValue({ + [Symbol.asyncIterator]: async function * () { + yield collaborators + } + }) + + const context: NotificationContext = { + id: contextId, + cardId, + account: accountUuid, + lastUpdate: date, + lastView: date, + lastNotify: date + } as any + + mockClient.db.findNotificationContexts.mockResolvedValue([context]) + + const result = await notify(mockCtx, event) + + // Should return successfully even if there are potential errors + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + }) + }) + + describe('ReactionPatch event - add', () => { + it('should create notification for reaction', async () => { + const date = new Date() + const reactionSocialId = 'reaction-social' as SocialID + const messageSocialId = 'message-social' as SocialID + const messageAccount = 'message-account' as AccountUuid + const reactionAccount = 'reaction-account' as AccountUuid + + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { + opcode: 'add', + reaction: '👍' + }, + socialId: reactionSocialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: messageSocialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid + .mockResolvedValueOnce(messageAccount) // message creator + .mockResolvedValueOnce(reactionAccount) // reaction creator + + mockClient.db.getCardSpaceMembers.mockResolvedValue([messageAccount, reactionAccount]) + + const context: NotificationContext = { + id: contextId, + cardId, + account: messageAccount, + lastUpdate: new Date(date.getTime() - 1000), + lastView: new Date(date.getTime() - 2000), + lastNotify: new Date(date.getTime() - 1000) + } as any + + mockClient.db.findNotificationContexts.mockResolvedValue([context]) + + const result = await notify(mockCtx, event) + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: NotificationEventType.CreateNotification, + notificationType: NotificationType.Reaction, + messageId + }) + ]) + ) + }) + + it('should return empty array when message meta is not found', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'add', reaction: '👍' }, + socialId, + date: new Date() + } as any + + mockClient.getMessageMeta.mockResolvedValue(undefined) + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + }) + + it('should return empty array when message account is not found', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'add', reaction: '👍' }, + socialId, + date: new Date() + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: Date.now() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue(undefined) + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + }) + + it('should return empty array when message account is not in space members', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'add', reaction: '👍' }, + socialId, + date: new Date() + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: Date.now() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue('other-account' as AccountUuid) + mockClient.db.getCardSpaceMembers.mockResolvedValue([accountUuid]) + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + }) + + it('should not notify when reacting to own message', async () => { + const date = new Date() + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'add', reaction: '👍' }, + socialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue(accountUuid) + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + }) + + it('should create context if it does not exist', async () => { + const date = new Date() + const otherSocialId = 'other-social' as SocialID + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'add', reaction: '👍' }, + socialId: otherSocialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid + .mockResolvedValueOnce(accountUuid) // message creator + .mockResolvedValueOnce('other-account' as AccountUuid) // reaction creator + + mockClient.db.findNotificationContexts.mockResolvedValue([]) + + const createContextResult: CreateNotificationContextResult = { id: contextId } + mockCtx.execute = jest.fn().mockResolvedValue(createContextResult) + + const result = await notify(mockCtx, event) + + expect(mockCtx.execute).toHaveBeenCalledWith( + expect.objectContaining({ + type: NotificationEventType.CreateNotificationContext + }) + ) + expect(result.length).toBeGreaterThan(0) + }) + + it('should update context lastNotify if reaction is newer', async () => { + const date = new Date() + const oldDate = new Date(date.getTime() - 10000) + const otherSocialId = 'other-social' as SocialID + + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'add', reaction: '👍' }, + socialId: otherSocialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: oldDate.getTime() + } as any + + const context: NotificationContext = { + id: contextId, + cardId, + account: accountUuid, + lastUpdate: oldDate, + lastView: oldDate, + lastNotify: oldDate + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid + .mockResolvedValueOnce(accountUuid) + .mockResolvedValueOnce('other-account' as AccountUuid) + mockClient.db.findNotificationContexts.mockResolvedValue([context]) + + const result = await notify(mockCtx, event) + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: NotificationEventType.UpdateNotificationContext, + updates: expect.objectContaining({ + lastNotify: date + }) + }) + ]) + ) + }) + + it('should mark notification as read for readOnlyGuestAccount', async () => { + const date = new Date() + const otherSocialId = 'other-social' as SocialID + + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'add', reaction: '👍' }, + socialId: otherSocialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid + .mockResolvedValueOnce(readOnlyGuestAccountUuid) // message creator + .mockResolvedValueOnce('other-account' as AccountUuid) // reaction creator + + // Make sure readOnlyGuestAccount is in space members + mockClient.db.getCardSpaceMembers.mockResolvedValue([readOnlyGuestAccountUuid, 'other-account' as AccountUuid]) + mockClient.db.findNotificationContexts.mockResolvedValue([]) + mockCtx.execute = jest.fn().mockResolvedValue({ id: contextId }) + + const result = await notify(mockCtx, event) + + const createNotification = result.find( + (e) => e.type === NotificationEventType.CreateNotification + ) + expect(createNotification).toBeDefined() + expect(createNotification).toMatchObject({ read: true }) + }) + }) + + describe('ReactionPatch event - remove', () => { + it('should remove reaction notification', async () => { + const date = new Date() + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'remove', reaction: '👍' }, + socialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + const notificationContent: Pick & { title: string, shortText: string, senderName: string } = { + emoji: '👍', + creator: socialId, + title: 'Reacted to your message', + shortText: '👍', + senderName: 'John Doe' + } + + const notification = { + id: 'notif-1', + contextId, + type: NotificationType.Reaction, + messageId, + account: accountUuid, + created: date, + content: notificationContent + } + + const context: NotificationContext = { + id: contextId, + cardId, + account: accountUuid, + lastUpdate: date, + lastView: date, + lastNotify: date + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue(accountUuid) + mockClient.db.findNotifications.mockResolvedValue([notification as any]) + mockClient.db.findNotificationContexts.mockResolvedValue([context]) + + const result = await notify(mockCtx, event) + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: NotificationEventType.RemoveNotifications, + ids: ['notif-1'] + }) + ]) + ) + }) + + it('should return empty array when notification not found', async () => { + const date = new Date() + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'remove', reaction: '👍' }, + socialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue(accountUuid) + mockClient.db.findNotifications.mockResolvedValue([]) + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + }) + + it('should update context lastNotify when removed notification was the last one', async () => { + const date = new Date() + const olderDate = new Date(date.getTime() - 5000) + + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'remove', reaction: '👍' }, + socialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + const notificationContent: Pick & { title: string, shortText: string, senderName: string } = { + emoji: '👍', + creator: socialId, + title: 'Reacted to your message', + shortText: '👍', + senderName: 'John Doe' + } + + const notification = { + id: 'notif-1', + contextId, + type: NotificationType.Reaction, + messageId, + account: accountUuid, + created: date, + content: notificationContent + } + + const olderNotification = { + id: 'notif-2', + contextId, + created: olderDate + } + + const context: NotificationContext = { + id: contextId, + cardId, + account: accountUuid, + lastUpdate: date, + lastView: olderDate, + lastNotify: date + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue(accountUuid) + mockClient.db.findNotifications + .mockResolvedValueOnce([notification as any]) + .mockResolvedValueOnce([olderNotification as any]) + mockClient.db.findNotificationContexts.mockResolvedValue([context]) + + const result = await notify(mockCtx, event) + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: NotificationEventType.UpdateNotificationContext, + updates: expect.objectContaining({ + lastNotify: olderDate + }) + }) + ]) + ) + }) + }) + + describe('Unknown event type', () => { + it('should return empty array for unknown event type', async () => { + const event: Enriched = { + type: 'unknown.event' as any, + date: new Date() + } as any + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + }) + }) + }) +}) diff --git a/packages/server/src/blob.ts b/packages/server/src/blob.ts index 7c9b5b7..181bf7c 100644 --- a/packages/server/src/blob.ts +++ b/packages/server/src/blob.ts @@ -44,6 +44,8 @@ import { v4 as uuid } from 'uuid' import { Metadata } from './types' +const LOG_CARD_ID = '67ecc702f182d88819f0a726' as CardID + export class Blob { private readonly client: HulylakeWorkspaceClient // Groups sored by fromDate @@ -100,7 +102,7 @@ export class Blob { } if (this.messageGroupsByCardId.has(cardId)) { - return this.messageGroupsByCardId.get(cardId) ?? [] + return (this.messageGroupsByCardId.get(cardId) ?? []).sort((a, b) => a.fromDate.getTime() - b.fromDate.getTime()) } const existingPromise = this.messageGroupsPromises.get(cardId) @@ -109,13 +111,17 @@ export class Blob { const promise = (async () => { try { const res = await this.client.getJson(`${cardId}/messages/groups`, this.retryOptions) - if (res?.body == null) { + if (res.status === 404) { await this.createMessagesGroupBlob(cardId) this.messageGroupsByCardId.set(cardId, []) return [] } - const groups = Object.values(res.body).map(it => this.deserializeMessageGroup(it)).sort((a, b) => a.fromDate.getTime() - b.fromDate.getTime()) + if (cardId === LOG_CARD_ID) { + this.ctx.info('Received groups', { groups: JSON.stringify(res.body ?? {}, undefined, 2) }) + } + + const groups = Object.values(res.body ?? {}).map(it => this.deserializeMessageGroup(it)).sort((a, b) => a.fromDate.getTime() - b.fromDate.getTime()) this.messageGroupsByCardId.set(cardId, groups) return groups } finally { @@ -129,18 +135,32 @@ export class Blob { public async getMessageGroupByDate (cardId: CardID, date: Date, create = true): Promise { const all = await this.getAllMessageGroups(cardId) + if (cardId === LOG_CARD_ID) { + this.ctx.info('all groups sorted', { cardId, sortedGroups: JSON.stringify(all, undefined, 2) }) + } const ts = date.getTime() const match = all.find(g => g.fromDate.getTime() <= ts && g.toDate.getTime() >= ts) - if (match != null) return match + if (match != null) { + if (cardId === LOG_CARD_ID) { + this.ctx.info('math group', { date, match: JSON.stringify(match, undefined, 2) }) + } + return match + } const lastGroup = all[all.length - 1] if (lastGroup != null && lastGroup.fromDate.getTime() <= ts && lastGroup.count < this.metadata.messagesPerBlob) { + if (cardId === LOG_CARD_ID) { + this.ctx.info('last group', { date, match: JSON.stringify(match, undefined, 2) }) + } return lastGroup } const firstGroup = all[0] if (firstGroup != null && firstGroup.fromDate.getTime() >= ts && firstGroup.count < this.metadata.messagesPerBlob) { + if (cardId === LOG_CARD_ID) { + this.ctx.info('first group', { date, match: JSON.stringify(match, undefined, 2) }) + } return firstGroup } @@ -235,7 +255,9 @@ export class Blob { await this.client.patchJson(`${cardId}/messages/groups`, patches, undefined, this.retryOptions) const group = this.deserializeMessageGroup(groupDoc) if (this.messageGroupsByCardId.has(cardId)) { - this.messageGroupsByCardId.set(cardId, [...this.messageGroupsByCardId.get(cardId) ?? [], group]) + this.messageGroupsByCardId.set(cardId, + [...this.messageGroupsByCardId.get(cardId) ?? [], group].sort((a, b) => a.fromDate.getTime() - b.fromDate.getTime()) + ) } else { this.messageGroupsByCardId.set(cardId, [group]) } @@ -264,6 +286,10 @@ export class Blob { } async insertMessage (cardId: CardID, group: MessagesGroup, message: Message): Promise { + if (cardId === LOG_CARD_ID && group.blobId === 'ad77d5d3-a073-4a14-960b-2f46e844bb6d"') { + this.ctx.error('SELECT WRONG GROUP!', { cardId, group, message, groups: this.messageGroupsByCardId.get(cardId) }) + throw new Error('Select wrong group') + } const updateToDate = message.created.getTime() > group.toDate.getTime() const updateFromDate = message.created.getTime() < group.fromDate.getTime() diff --git a/packages/server/src/middleware/triggers.ts b/packages/server/src/middleware/triggers.ts index c9413d5..d203be1 100644 --- a/packages/server/src/middleware/triggers.ts +++ b/packages/server/src/middleware/triggers.ts @@ -46,6 +46,11 @@ export class TriggersMiddleware extends BaseMiddleware implements Middleware { } async processDerived (session: SessionData, events: Enriched[], derived: boolean): Promise { + // Ensure asyncData is initialized + if (session.asyncData === undefined) { + session.asyncData = [] + } + const triggerCtx: Omit = { metadata: this.context.metadata, client: this.context.client, diff --git a/packages/server/src/middleware/validate.ts b/packages/server/src/middleware/validate.ts index 3b00c9f..b2142e1 100644 --- a/packages/server/src/middleware/validate.ts +++ b/packages/server/src/middleware/validate.ts @@ -25,9 +25,11 @@ import { type Collaborator, type FindCollaboratorsParams, type FindLabelsParams, + FindMessagesGroupParams, type FindNotificationContextParams, type FindNotificationsParams, type Label, + MessagesGroup, type Notification, type NotificationContext, SortingOrder @@ -50,6 +52,11 @@ export class ValidateMiddleware extends BaseMiddleware implements Middleware { return validationResult.data } + async findMessagesGroups (session: SessionData, params: FindMessagesGroupParams): Promise { + this.validate(params, FindMessagesGroupsParamsSchema) + return await this.provideFindMessagesGroups(session, params) + } + async findNotificationContexts ( session: SessionData, params: FindNotificationContextParams, @@ -226,6 +233,14 @@ const FindNotificationContextParamsSchema = FindParamsSchema.extend({ .optional() }).strict() +const FindMessagesGroupsParamsSchema = FindParamsSchema.extend({ + cardId: CardIDSchema, + id: MessageIDSchema.optional(), + blobId: BlobIDSchema.optional(), + fromDate: DateOrRecordSchema.optional(), + toDate: DateOrRecordSchema.optional() +}).strict() + const FindNotificationsParamsSchema = FindParamsSchema.extend({ contextId: ContextIDSchema.optional(), type: z.string().optional(), diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index c3fbcfb..2ad347b 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -6,7 +6,7 @@ "outDir": "./lib", "declarationDir": "./types", "tsBuildInfoFile": ".build/build.tsbuildinfo", - "types": ["node"] + "types": ["node", "jest"] }, "include": ["src/**/*"], "exclude": ["node_modules", "lib", "dist", "types", "bundle"] diff --git a/packages/types/package.json b/packages/types/package.json index b3a4789..cf225a3 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -36,7 +36,7 @@ "typescript": "^5.8.3" }, "dependencies": { - "@hcengineering/core": "^0.7.7" + "@hcengineering/core": "^0.7.8" }, "repository": { "type": "git", From ad4ab3704ea82c4b9a66df99a857f2f90d693158 Mon Sep 17 00:00:00 2001 From: Kristina Fefelova Date: Fri, 17 Oct 2025 17:37:46 +0400 Subject: [PATCH 2/5] Drop franc min Signed-off-by: Kristina Fefelova --- common/config/rush/pnpm-lock.yaml | 28 -- packages/server/package.json | 1 - packages/server/src/__mocks__/franc-min.ts | 3 - packages/server/src/__tests__/index.test.ts | 5 - packages/server/src/middleware/language.ts | 273 -------------------- packages/server/src/middlewares.ts | 2 - 6 files changed, 312 deletions(-) delete mode 100644 packages/server/src/__mocks__/franc-min.ts delete mode 100644 packages/server/src/middleware/language.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index a77fbbd..5390b13 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -353,9 +353,6 @@ importers: '@hcengineering/text-markdown': specifier: ^0.7.5 version: 0.7.5 - franc-min: - specifier: ^6.2.0 - version: 6.2.0 uuid: specifier: ^8.3.2 version: 8.3.2 @@ -1552,9 +1549,6 @@ packages: code-red@1.0.4: resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} - collapse-white-space@2.1.0: - resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} - collect-v8-coverage@1.0.2: resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} @@ -1959,9 +1953,6 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - franc-min@6.2.0: - resolution: {integrity: sha512-1uDIEUSlUZgvJa2AKYR/dmJC66v/PvGQ9mWfI9nOr/kPpMFyvswK0gPXOwpYJYiYD008PpHLkGfG58SPjQJFxw==} - fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -2566,9 +2557,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - n-gram@2.0.2: - resolution: {integrity: sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ==} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3078,9 +3066,6 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - trigram-utils@2.0.1: - resolution: {integrity: sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ==} - ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -4394,8 +4379,6 @@ snapshots: estree-walker: 3.0.3 periscopic: 3.1.0 - collapse-white-space@2.1.0: {} - collect-v8-coverage@1.0.2: {} color-convert@2.0.1: @@ -4963,10 +4946,6 @@ snapshots: dependencies: is-callable: 1.2.7 - franc-min@6.2.0: - dependencies: - trigram-utils: 2.0.1 - fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -5757,8 +5736,6 @@ snapshots: ms@2.1.3: {} - n-gram@2.0.2: {} - nanoid@3.3.11: {} natural-compare@1.4.0: {} @@ -6261,11 +6238,6 @@ snapshots: dependencies: is-number: 7.0.0 - trigram-utils@2.0.1: - dependencies: - collapse-white-space: 2.1.0 - n-gram: 2.0.2 - ts-api-utils@1.4.3(typescript@5.9.3): dependencies: typescript: 5.9.3 diff --git a/packages/server/package.json b/packages/server/package.json index f890404..813ecd2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -51,7 +51,6 @@ "@hcengineering/text-core": "^0.7.5", "@hcengineering/text-markdown": "^0.7.5", "@hcengineering/hulylake-client": "^0.7.6", - "franc-min": "^6.2.0", "uuid": "^8.3.2", "zod": "^3.22.4" }, diff --git a/packages/server/src/__mocks__/franc-min.ts b/packages/server/src/__mocks__/franc-min.ts deleted file mode 100644 index 1e43842..0000000 --- a/packages/server/src/__mocks__/franc-min.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Mock for franc-min ESM module -export const franc = jest.fn().mockReturnValue('eng') -export const francAll = jest.fn().mockReturnValue([['eng', 1]]) diff --git a/packages/server/src/__tests__/index.test.ts b/packages/server/src/__tests__/index.test.ts index aaf9b57..36b331d 100644 --- a/packages/server/src/__tests__/index.test.ts +++ b/packages/server/src/__tests__/index.test.ts @@ -37,11 +37,6 @@ import { buildMiddlewares } from '../middlewares' import { Blob } from '../blob' import { LowLevelClient } from '../client' -// Mock franc-min before any imports that use it -jest.mock('franc-min', () => ({ - franc: jest.fn(() => 'eng') -})) - // Mock dependencies jest.mock('@hcengineering/communication-cockroach') jest.mock('../metadata') diff --git a/packages/server/src/middleware/language.ts b/packages/server/src/middleware/language.ts deleted file mode 100644 index 3160e84..0000000 --- a/packages/server/src/middleware/language.ts +++ /dev/null @@ -1,273 +0,0 @@ -// -// Copyright © 2025 Hardcore Engineering Inc. -// -// Licensed under the Eclipse Public License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. You may -// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import { type Event, EventResult, MessageEventType, type SessionData } from '@hcengineering/communication-sdk-types' -import { franc } from 'franc-min' -import { Markdown } from '@hcengineering/communication-types' -import { markdownToMarkup } from '@hcengineering/text-markdown' -import { - isEmptyMarkup, - jsonToMarkup, - MarkupMarkType, - MarkupNode, - MarkupNodeType, - traverseNode -} from '@hcengineering/text-core' - -import type { Enriched, Middleware, MiddlewareContext } from '../types' -import { BaseMiddleware } from './base' - -export class LanguageMiddleware extends BaseMiddleware implements Middleware { - constructor ( - readonly context: MiddlewareContext, - next?: Middleware - ) { - super(context, next) - } - - async event (session: SessionData, event: Enriched, derived: boolean): Promise { - if (event.type === MessageEventType.CreateMessage) { - event.language = event.language ?? this.getLanguage(event.content) - } - if (event.type === MessageEventType.UpdatePatch && event.content != null && event.content.trim() !== '') { - event.language = event.language ?? this.getLanguage(event.content) - } - - return await this.provideEvent(session, event, derived) - } - - private getLanguage (content: Markdown): string | undefined { - const markupNode = markdownToMarkup(content) - const markup = jsonToMarkup(markupNode) - if (isEmptyMarkup(markup)) return undefined - const text = this.toText(markupNode).trim() - if (text === '') return - - return toIso6391(franc(text, {})) - } - - private toText (markup: MarkupNode): string { - const fragments: string[] = [] - - traverseNode(markup, (node) => { - if (node.type === MarkupNodeType.text) { - const text = node.text ?? '' - const linkMark = (node.marks ?? []).find((it) => it.type === MarkupMarkType.link) - if (linkMark != null && linkMark.attrs?.href === text) { - return true - } - if (node.text !== undefined && node.text.length > 0) { - fragments.push(text) - } - } else if (node.type === MarkupNodeType.paragraph) { - fragments.push(' ') - } - return true - }) - - return fragments.join('').trim() - } -} - -const iso6393To1 = { - aar: 'aa', - abk: 'ab', - afr: 'af', - aka: 'ak', - amh: 'am', - ara: 'ar', - arg: 'an', - asm: 'as', - ava: 'av', - ave: 'ae', - aym: 'ay', - aze: 'az', - bak: 'ba', - bam: 'bm', - bel: 'be', - ben: 'bn', - bis: 'bi', - bod: 'bo', - bos: 'bs', - bre: 'br', - bul: 'bg', - cat: 'ca', - ces: 'cs', - cha: 'ch', - che: 'ce', - chu: 'cu', - chv: 'cv', - cor: 'kw', - cos: 'co', - cre: 'cr', - cym: 'cy', - dan: 'da', - deu: 'de', - div: 'dv', - dzo: 'dz', - ell: 'el', - eng: 'en', - epo: 'eo', - est: 'et', - eus: 'eu', - ewe: 'ee', - fao: 'fo', - fas: 'fa', - fij: 'fj', - fin: 'fi', - fra: 'fr', - fry: 'fy', - ful: 'ff', - gla: 'gd', - gle: 'ga', - glg: 'gl', - glv: 'gv', - grn: 'gn', - guj: 'gu', - hat: 'ht', - hau: 'ha', - hbs: 'sh', - heb: 'he', - her: 'hz', - hin: 'hi', - hmo: 'ho', - hrv: 'hr', - hun: 'hu', - hye: 'hy', - ibo: 'ig', - ido: 'io', - iii: 'ii', - iku: 'iu', - ile: 'ie', - ina: 'ia', - ind: 'id', - ipk: 'ik', - isl: 'is', - ita: 'it', - jav: 'jv', - jpn: 'ja', - kal: 'kl', - kan: 'kn', - kas: 'ks', - kat: 'ka', - kau: 'kr', - kaz: 'kk', - khm: 'km', - kik: 'ki', - kin: 'rw', - kir: 'ky', - kom: 'kv', - kon: 'kg', - kor: 'ko', - kua: 'kj', - kur: 'ku', - lao: 'lo', - lat: 'la', - lav: 'lv', - lim: 'li', - lin: 'ln', - lit: 'lt', - ltz: 'lb', - lub: 'lu', - lug: 'lg', - mah: 'mh', - mal: 'ml', - mar: 'mr', - mkd: 'mk', - mlg: 'mg', - mlt: 'mt', - mon: 'mn', - mri: 'mi', - msa: 'ms', - mya: 'my', - nau: 'na', - nav: 'nv', - nbl: 'nr', - nde: 'nd', - ndo: 'ng', - nep: 'ne', - nld: 'nl', - nno: 'nn', - nob: 'nb', - nor: 'no', - nya: 'ny', - oci: 'oc', - oji: 'oj', - ori: 'or', - orm: 'om', - oss: 'os', - pan: 'pa', - pli: 'pi', - pol: 'pl', - por: 'pt', - pus: 'ps', - que: 'qu', - roh: 'rm', - ron: 'ro', - run: 'rn', - rus: 'ru', - sag: 'sg', - san: 'sa', - sin: 'si', - slk: 'sk', - slv: 'sl', - sme: 'se', - smo: 'sm', - sna: 'sn', - snd: 'sd', - som: 'so', - sot: 'st', - spa: 'es', - sqi: 'sq', - srd: 'sc', - srp: 'sr', - ssw: 'ss', - sun: 'su', - swa: 'sw', - swe: 'sv', - tah: 'ty', - tam: 'ta', - tat: 'tt', - tel: 'te', - tgk: 'tg', - tgl: 'tl', - tha: 'th', - tir: 'ti', - ton: 'to', - tsn: 'tn', - tso: 'ts', - tuk: 'tk', - tur: 'tr', - twi: 'tw', - uig: 'ug', - ukr: 'uk', - urd: 'ur', - uzb: 'uz', - ven: 've', - vie: 'vi', - vol: 'vo', - wln: 'wa', - wol: 'wo', - xho: 'xh', - yid: 'yi', - yor: 'yo', - zha: 'za', - zho: 'zh', - zul: 'zu' -} - -function toIso6391 (lang: string): string | undefined { - return (iso6393To1 as any)[lang] -} diff --git a/packages/server/src/middlewares.ts b/packages/server/src/middlewares.ts index c2da07f..ec6a66e 100644 --- a/packages/server/src/middlewares.ts +++ b/packages/server/src/middlewares.ts @@ -48,7 +48,6 @@ import { IdentityMiddleware } from './middleware/indentity' import { IdMiddleware } from './middleware/id' import { PeerMiddleware } from './middleware/peer' import { LowLevelClient } from './client' -import { LanguageMiddleware } from './middleware/language' export async function buildMiddlewares ( ctx: MeasureContext, @@ -64,7 +63,6 @@ export async function buildMiddlewares ( async (context, next) => new DateMiddleware(context, next), async (context, next) => new IdentityMiddleware(context, next), async (context, next) => new IdMiddleware(context, next), - async (context, next) => new LanguageMiddleware(context, next), // Validate events async (context, next) => new ValidateMiddleware(context, next), From 92d2a87c76fd534aa34728e526e76499ec57667a Mon Sep 17 00:00:00 2001 From: Kristina Fefelova Date: Mon, 20 Oct 2025 09:46:51 +0400 Subject: [PATCH 3/5] fix github actions Signed-off-by: Kristina Fefelova --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44094b3..63bb264 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,8 @@ jobs: run: node common/scripts/install-run-rush.js install - name: Rush validate run: node common/scripts/install-run-rush.js validate --verbose - - name: Rush test - run: node common/scripts/install-run-rush.js test --verbose + - name: Rush test + run: node common/scripts/install-run-rush.js test --verbose - name: Publish packages if: startsWith(github.ref, 'refs/tags/v0.7.') || startsWith(github.ref, 'refs/tags/s0.7.') env: From f2d41dd5565cc57c6fb92943c6a0aa1e8beef9c7 Mon Sep 17 00:00:00 2001 From: Kristina Fefelova Date: Mon, 20 Oct 2025 09:49:08 +0400 Subject: [PATCH 4/5] Add description Signed-off-by: Kristina Fefelova --- .../tests_2025-10-20-05-48.json | 10 ++++++++++ .../tests_2025-10-20-05-48.json | 10 ++++++++++ .../communication-server/tests_2025-10-20-05-48.json | 10 ++++++++++ .../communication-types/tests_2025-10-20-05-48.json | 10 ++++++++++ 4 files changed, 40 insertions(+) create mode 100644 common/changes/@hcengineering/communication-rest-client/tests_2025-10-20-05-48.json create mode 100644 common/changes/@hcengineering/communication-sdk-types/tests_2025-10-20-05-48.json create mode 100644 common/changes/@hcengineering/communication-server/tests_2025-10-20-05-48.json create mode 100644 common/changes/@hcengineering/communication-types/tests_2025-10-20-05-48.json diff --git a/common/changes/@hcengineering/communication-rest-client/tests_2025-10-20-05-48.json b/common/changes/@hcengineering/communication-rest-client/tests_2025-10-20-05-48.json new file mode 100644 index 0000000..4a90613 --- /dev/null +++ b/common/changes/@hcengineering/communication-rest-client/tests_2025-10-20-05-48.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/communication-rest-client", + "comment": "Bump core", + "type": "patch" + } + ], + "packageName": "@hcengineering/communication-rest-client" +} \ No newline at end of file diff --git a/common/changes/@hcengineering/communication-sdk-types/tests_2025-10-20-05-48.json b/common/changes/@hcengineering/communication-sdk-types/tests_2025-10-20-05-48.json new file mode 100644 index 0000000..d6b12b8 --- /dev/null +++ b/common/changes/@hcengineering/communication-sdk-types/tests_2025-10-20-05-48.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/communication-sdk-types", + "comment": "Bump core", + "type": "patch" + } + ], + "packageName": "@hcengineering/communication-sdk-types" +} \ No newline at end of file diff --git a/common/changes/@hcengineering/communication-server/tests_2025-10-20-05-48.json b/common/changes/@hcengineering/communication-server/tests_2025-10-20-05-48.json new file mode 100644 index 0000000..0e47529 --- /dev/null +++ b/common/changes/@hcengineering/communication-server/tests_2025-10-20-05-48.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/communication-server", + "comment": "Add tests", + "type": "patch" + } + ], + "packageName": "@hcengineering/communication-server" +} \ No newline at end of file diff --git a/common/changes/@hcengineering/communication-types/tests_2025-10-20-05-48.json b/common/changes/@hcengineering/communication-types/tests_2025-10-20-05-48.json new file mode 100644 index 0000000..ae2d300 --- /dev/null +++ b/common/changes/@hcengineering/communication-types/tests_2025-10-20-05-48.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/communication-types", + "comment": "Bump core", + "type": "patch" + } + ], + "packageName": "@hcengineering/communication-types" +} \ No newline at end of file From 589c8303719dead03444eec19e8946faca30e793 Mon Sep 17 00:00:00 2001 From: Kristina Fefelova Date: Mon, 20 Oct 2025 09:58:26 +0400 Subject: [PATCH 5/5] Add rush test Signed-off-by: Kristina Fefelova --- common/config/rush/command-line.json | 26 ++++- common/scripts/run-tests-with-coverage.js | 116 +++++++++++++++++++++ common/scripts/show-coverage-summary.js | 117 ++++++++++++++++++++++ common/scripts/show-coverage-summary.sh | 76 ++++++++++++++ common/scripts/show-coverage.sh | 27 +++++ 5 files changed, 361 insertions(+), 1 deletion(-) create mode 100755 common/scripts/run-tests-with-coverage.js create mode 100755 common/scripts/show-coverage-summary.js create mode 100755 common/scripts/show-coverage-summary.sh create mode 100755 common/scripts/show-coverage.sh diff --git a/common/config/rush/command-line.json b/common/config/rush/command-line.json index 0658dd2..5064b33 100644 --- a/common/config/rush/command-line.json +++ b/common/config/rush/command-line.json @@ -30,9 +30,34 @@ }, "ignoreMissingScript": true, "allowWarningsOnSuccess": false + }, + { + "name": "_phase:test", + "dependencies": { + "self": ["_phase:build"], + "upstream": ["_phase:validate"] + }, + "ignoreMissingScript": true, + "allowWarningsOnSuccess": true } ], "commands": [ + { + "commandKind": "global", + "name": "coverage", + "summary": "Run tests, merge LCOV and generate HTML coverage", + "description": "Run 'rush test', then merge per-package LCOV files and generate HTML coverage in coverage/html", + "safeForSimultaneousRushProcesses": true, + "shellCommand": "rush test && node scripts/merge-coverage.js && node scripts/generate-coverage-html.js coverage/lcov.info coverage/html" + }, + { + "commandKind": "phased", + "summary": "Do testing", + "name": "test", + "phases": ["_phase:build", "_phase:test"], + "enableParallelism": true, + "incremental": true + }, { "commandKind": "phased", "name": "build", @@ -95,7 +120,6 @@ "shellCommand": "./common/scripts/node_modules/.bin/bump-changes-from-tag" } ], - /** * Custom "parameters" introduce new parameters for specified Rush command-line commands. * For example, you might define a "--production" parameter for the "rush build" command. diff --git a/common/scripts/run-tests-with-coverage.js b/common/scripts/run-tests-with-coverage.js new file mode 100755 index 0000000..150cd21 --- /dev/null +++ b/common/scripts/run-tests-with-coverage.js @@ -0,0 +1,116 @@ +#!/usr/bin/env node + +/** + * Run tests with coverage for all packages + * This is a replacement for show-coverage.sh + */ + +const fs = require('fs') +const path = require('path') +const { spawn } = require('child_process') + +const root = process.cwd() +const packagesDir = path.join(root, 'packages') + +if (!fs.existsSync(packagesDir)) { + console.error('Error: packages directory not found') + process.exit(1) +} + +console.log('=== RUNNING TESTS WITH COVERAGE ===') +console.log('') + +const packages = fs + .readdirSync(packagesDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name) + .sort() + +async function runTestsForPackage(pkgName) { + const pkgDir = path.join(packagesDir, pkgName) + const packageJson = path.join(pkgDir, 'package.json') + + if (!fs.existsSync(packageJson)) { + return null + } + + const pkg = JSON.parse(fs.readFileSync(packageJson, 'utf8')) + if (!pkg.scripts || !pkg.scripts.test) { + return null + } + + console.log(`📦 Package: ${pkgName}`) + console.log('---') + + return new Promise((resolve) => { + const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm' + const child = spawn(npm, ['test', '--', '--coverage', '--silent'], { + cwd: pkgDir, + stdio: 'pipe', + shell: true + }) + + let output = '' + let inSummary = false + + child.stdout.on('data', (data) => { + const text = data.toString() + output += text + + // Extract coverage summary + const lines = text.split('\n') + for (const line of lines) { + if (line.includes('Coverage summary') || line.includes('----------')) { + inSummary = true + } + if ( + inSummary && + (line.includes('Statements') || + line.includes('Branches') || + line.includes('Functions') || + line.includes('Lines')) + ) { + console.log(line.trim()) + } + if (inSummary && line.trim() === '') { + inSummary = false + } + } + }) + + child.stderr.on('data', (data) => { + // Ignore stderr for cleaner output + }) + + child.on('close', (code) => { + console.log('') + resolve({ package: pkgName, code, output }) + }) + }) +} + +async function main() { + const results = [] + + for (const pkg of packages) { + const result = await runTestsForPackage(pkg) + if (result) { + results.push(result) + } + } + + console.log('=== END OF COVERAGE REPORT ===') + console.log('') + console.log(`Tested ${results.length} packages`) + + const failed = results.filter((r) => r.code !== 0) + if (failed.length > 0) { + console.log(`⚠️ ${failed.length} package(s) had test failures:`) + failed.forEach((r) => console.log(` - ${r.package}`)) + } +} + +main().catch((err) => { + console.error('Error:', err) + process.exit(1) +}) diff --git a/common/scripts/show-coverage-summary.js b/common/scripts/show-coverage-summary.js new file mode 100755 index 0000000..f2ca9e9 --- /dev/null +++ b/common/scripts/show-coverage-summary.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node + +/** + * Display coverage summary from lcov.info file + * Usage: node show-coverage-summary.js [lcov-file-path] + */ + +const fs = require('fs') +const path = require('path') + +const lcovFile = process.argv[2] || 'coverage/lcov.info' +const root = process.cwd() +const lcovPath = path.isAbsolute(lcovFile) ? lcovFile : path.join(root, lcovFile) + +if (!fs.existsSync(lcovPath)) { + console.error(`Error: LCOV file not found: ${lcovPath}`) + process.exit(1) +} + +console.log('==============================================') +console.log('COVERAGE SUMMARY BY PACKAGE') +console.log('==============================================') +console.log('') + +const data = fs.readFileSync(lcovPath, 'utf8') +const lines = data.split(/\r?\n/) + +const fileStats = new Map() +const filePkg = new Map() +let currentFile = '' + +// Parse lcov.info +for (const line of lines) { + if (line.startsWith('SF:')) { + currentFile = line.substring(3) + fileStats.set(currentFile, { total: 0, covered: 0 }) + + // Extract package name from path + const parts = currentFile.split(path.sep) + const pkgIndex = parts.indexOf('packages') + if (pkgIndex !== -1 && pkgIndex + 1 < parts.length) { + filePkg.set(currentFile, parts[pkgIndex + 1]) + } + } else if (line.startsWith('DA:')) { + const [lineNum, hitCount] = line.substring(3).split(',') + const stats = fileStats.get(currentFile) + if (stats) { + stats.total++ + if (parseInt(hitCount) > 0) { + stats.covered++ + } + } + } +} + +// Aggregate by package +const pkgStats = new Map() +for (const [file, stats] of fileStats) { + const pkg = filePkg.get(file) + if (pkg) { + if (!pkgStats.has(pkg)) { + pkgStats.set(pkg, { total: 0, covered: 0 }) + } + const pkgStat = pkgStats.get(pkg) + pkgStat.total += stats.total + pkgStat.covered += stats.covered + } +} + +// Sort packages alphabetically +const sortedPackages = Array.from(pkgStats.keys()).sort() + +// Display header +console.log(padRight('Package', 25) + padLeft('Covered', 10) + padLeft('Total', 10) + padLeft('Coverage', 10)) +console.log('----------------------------------------------') + +// Display package stats +let overallCovered = 0 +let overallTotal = 0 + +for (const pkg of sortedPackages) { + const stats = pkgStats.get(pkg) + const pct = (stats.covered / stats.total) * 100 + console.log( + padRight(pkg, 25) + + padLeft(stats.covered.toString(), 10) + + padLeft(stats.total.toString(), 10) + + padLeft(pct.toFixed(2) + '%', 10) + ) + overallCovered += stats.covered + overallTotal += stats.total +} + +// Display total +console.log('----------------------------------------------') +const overallPct = (overallCovered / overallTotal) * 100 +console.log( + padRight('TOTAL', 25) + + padLeft(overallCovered.toString(), 10) + + padLeft(overallTotal.toString(), 10) + + padLeft(overallPct.toFixed(2) + '%', 10) +) +console.log('') + +console.log('==============================================') +console.log('') +console.log('HTML report available at: coverage/html/index.html') +console.log(`Merged LCOV file available at: ${lcovFile}`) + +// Helper functions +function padRight(str, width) { + return str + ' '.repeat(Math.max(0, width - str.length)) +} + +function padLeft(str, width) { + return ' '.repeat(Math.max(0, width - str.length)) + str +} diff --git a/common/scripts/show-coverage-summary.sh b/common/scripts/show-coverage-summary.sh new file mode 100755 index 0000000..7ae68f8 --- /dev/null +++ b/common/scripts/show-coverage-summary.sh @@ -0,0 +1,76 @@ +git#!/bin/bash + +# Script to display coverage summary from lcov.info file + +LCOV_FILE="${1:-coverage/lcov.info}" + +if [ ! -f "$LCOV_FILE" ]; then + echo "Error: LCOV file not found: $LCOV_FILE" + exit 1 +fi + +echo "==============================================" +echo "COVERAGE SUMMARY BY PACKAGE" +echo "==============================================" +echo "" + +# Parse lcov.info and aggregate by package +awk ' +BEGIN { + current_file = ""; +} +/^SF:/ { + current_file = $0; + sub(/^SF:/, "", current_file); + # Extract package name from path + split(current_file, parts, "/"); + pkg = ""; + for (i=1; i<=length(parts); i++) { + if (parts[i] == "packages" && i+1 <= length(parts)) { + pkg = parts[i+1]; + break; + } + } + total_lines[current_file] = 0; + covered_lines[current_file] = 0; + file_pkg[current_file] = pkg; +} +/^DA:/ { + split($0, parts, ","); + total_lines[current_file]++; + if (parts[2] > 0) covered_lines[current_file]++; +} +END { + # Aggregate by package + for (file in total_lines) { + pkg = file_pkg[file]; + if (pkg != "") { + pkg_total[pkg] += total_lines[file]; + pkg_covered[pkg] += covered_lines[file]; + } + } + + printf "%-25s %10s %10s %10s\n", "Package", "Covered", "Total", "Coverage"; + print "----------------------------------------------"; + + overall_covered = 0; + overall_total = 0; + + # Display packages + for (pkg in pkg_total) { + pct = (pkg_covered[pkg] / pkg_total[pkg]) * 100; + printf "%-25s %10d %10d %9.2f%%\n", pkg, pkg_covered[pkg], pkg_total[pkg], pct; + overall_covered += pkg_covered[pkg]; + overall_total += pkg_total[pkg]; + } + + print "----------------------------------------------"; + overall_pct = (overall_covered / overall_total) * 100; + printf "%-25s %10d %10d %9.2f%%\n", "TOTAL", overall_covered, overall_total, overall_pct; + print ""; +}' "$LCOV_FILE" + +echo "==============================================" +echo "" +echo "HTML report available at: coverage/html/index.html" +echo "Merged LCOV file available at: $LCOV_FILE" diff --git a/common/scripts/show-coverage.sh b/common/scripts/show-coverage.sh new file mode 100755 index 0000000..cc99b27 --- /dev/null +++ b/common/scripts/show-coverage.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +echo "=== FINAL COVERAGE REPORT ===" +echo "" + +# Iterate through each package directory +for pkg in packages/*/; do + pkgname=$(basename "$pkg") + + echo "📦 Package: $pkgname" + echo "---" + + # Change to package directory + cd "$pkg" || continue + + # Run tests with coverage and extract summary + npm test -- --coverage --silent 2>&1 | \ + grep -A 4 "Coverage summary" | \ + grep -E "Statements|Branches|Functions|Lines" + + # Return to root directory + cd ../.. || exit + + echo "" +done + +echo "=== END OF COVERAGE REPORT ===" \ No newline at end of file