diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 62c6cb1bb7..6f3cbd205b 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -591,7 +591,7 @@ importers: version: file:projects/model-card.tgz '@rush-temp/model-chat': specifier: file:./projects/model-chat.tgz - version: file:projects/model-chat.tgz + version: file:projects/model-chat.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) '@rush-temp/model-chunter': specifier: file:./projects/model-chunter.tgz version: file:projects/model-chunter.tgz @@ -1678,9 +1678,6 @@ importers: '@types/htmlparser2': specifier: ^3.10.7 version: 3.10.7 - '@types/jest': - specifier: ^29.5.5 - version: 29.5.12 '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -2302,9 +2299,6 @@ importers: toposort: specifier: ^2.0.2 version: 2.0.2 - ts-jest: - specifier: ^29.1.1 - version: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3) ts-loader: specifier: ^9.2.5 version: 9.5.1(typescript@5.8.3)(webpack@5.97.1) @@ -4890,7 +4884,7 @@ packages: version: 0.0.0 '@rush-temp/love-resources@file:projects/love-resources.tgz': - resolution: {integrity: sha512-KzBDtWbDzztfPmsNo9D7m7wf5tebLLMK1cDJP/o5MDJYsS7exjWimlGKAhKKb/iQ4CCjQLT9HwyEhhEPIlbi9g==, tarball: file:projects/love-resources.tgz} + resolution: {integrity: sha512-FOzneXg+JMRX2/EeEWd4w/lPJMBkSbDespBkMM479bLjcOwCrjswt/hI2uGHO7Pi0gvVVczrWwzp2eVVpCU81A==, tarball: file:projects/love-resources.tgz} version: 0.0.0 '@rush-temp/love@file:projects/love.tgz': @@ -4982,7 +4976,7 @@ packages: version: 0.0.0 '@rush-temp/model-chat@file:projects/model-chat.tgz': - resolution: {integrity: sha512-64oSh/Vi61UW9byPXcxGfeV1lPT0GtZ9FSlu2p+FdkLqptO4tUmlHE7GMveKoL+OjM64njjOJjqkR0zvRNgy0w==, tarball: file:projects/model-chat.tgz} + resolution: {integrity: sha512-dVgwzzmaQjHprWR3mEu1EO5J5azTvzNOyOYBI+AYRtqGHaPLAUGOVFKrebcCdbyJgUhq7wp7aDzq3+r75/fQ+w==, tarball: file:projects/model-chat.tgz} version: 0.0.0 '@rush-temp/model-chunter@file:projects/model-chunter.tgz': @@ -22093,8 +22087,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@rush-temp/model-chat@file:projects/model-chat.tgz': + '@rush-temp/model-chat@file:projects/model-chat.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3))': dependencies: + '@types/jest': 29.5.12 '@types/node': 22.15.29 '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.8.3) @@ -22103,10 +22098,19 @@ snapshots: eslint-plugin-import: 2.29.1(eslint@8.56.0) eslint-plugin-n: 15.7.0(eslint@8.56.0) eslint-plugin-promise: 6.1.1(eslint@8.56.0) + jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) prettier: 3.2.5 + ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: + - '@babel/core' + - '@jest/types' + - babel-jest + - babel-plugin-macros + - esbuild + - node-notifier - supports-color + - ts-node '@rush-temp/model-chunter@file:projects/model-chunter.tgz': dependencies: diff --git a/models/chat/jest.config.js b/models/chat/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/models/chat/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/models/chat/package.json b/models/chat/package.json index a09d8ac6fa..393e56ac18 100644 --- a/models/chat/package.json +++ b/models/chat/package.json @@ -11,9 +11,11 @@ "build": "compile", "build:watch": "compile", "format": "format src", + "test": "jest --passWithNoTests --silent --forceExit", "_phase:build": "compile transpile src", "_phase:format": "format src", - "_phase:validate": "compile validate" + "_phase:validate": "compile validate", + "_phase:test": "jest --passWithNoTests --silent --forceExit" }, "devDependencies": { "@hcengineering/platform-rig": "^0.6.0", @@ -26,7 +28,10 @@ "eslint-config-standard-with-typescript": "^40.0.0", "prettier": "^3.1.0", "typescript": "^5.8.3", - "@types/node": "^22.15.29" + "@types/node": "^22.15.29", + "jest": "^29.7.0", + "@types/jest": "^29.5.5", + "ts-jest": "^29.1.1" }, "dependencies": { "@hcengineering/card": "^0.6.0", diff --git a/models/chat/src/__tests__/migration.test.ts b/models/chat/src/__tests__/migration.test.ts new file mode 100644 index 0000000000..4a7e6fabaf --- /dev/null +++ b/models/chat/src/__tests__/migration.test.ts @@ -0,0 +1,211 @@ +// +// 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 { Card, MasterTag, ParentInfo } from '@hcengineering/card' +import type { Ref } from '@hcengineering/core' + +// Import after mocking +import { performParentInfoMigration } from '../migration' + +// Mock the chat plugin +jest.mock('../plugin', () => ({ + masterTag: { + Thread: 'chat:masterTag:Thread' as Ref + } +})) + +// Mock card plugin +jest.mock('@hcengineering/card', () => ({ + class: { + Card: 'card:class:Card' + }, + DOMAIN_CARD: 'card' +})) + +// Helper function to create mock cards +function createMockCard (id: string, parentInfo?: ParentInfo[]): Card { + const card: Card = { + _id: id as any, + _class: 'card:class:Card' as any, + title: `Card ${id}`, + content: { __ref: 'blob1' } as any, + blobs: {} as any, + rank: '1', + space: 'space1' as any, + modifiedBy: 'user1' as any, + modifiedOn: Date.now(), + createdBy: 'user1' as any, + createdOn: Date.now(), + parentInfo: parentInfo ?? [] + } + return card +} + +describe('migrateParentInfo', () => { + let mockClient: any + let mockIterator: any + let mockLogger: any + + const channelMasterTag = 'chat:masterTag:Channel' as Ref + + beforeEach(() => { + mockLogger = { + log: jest.fn(), + error: jest.fn() + } + + mockIterator = { + next: jest.fn(), + close: jest.fn() + } + + mockClient = { + traverse: jest.fn().mockResolvedValue(mockIterator), + bulk: jest.fn(), + logger: mockLogger + } + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should migrate cards with channel master tag in parentInfo using custom bulk size', async () => { + const cardsWithChannelParent: Card[] = [ + createMockCard('card1', [ + { + _class: channelMasterTag, + title: 'Channel 1', + _id: 'channel1' as Ref + } + ]), + createMockCard('card2', [ + { + _id: 'channel2' as Ref, + _class: channelMasterTag, + title: 'Channel 2' + } + ]) + ] + + // Mock iterator to return cards in first call, then empty + mockIterator.next.mockResolvedValueOnce(cardsWithChannelParent).mockResolvedValueOnce([]) + + // Use smaller bulk size for testing + await performParentInfoMigration(mockClient, 2) + + // Verify iterator.next was called with the correct bulk size + expect(mockIterator.next).toHaveBeenCalledWith(2) + + const mockChatMasterTag = 'chat:masterTag:Thread' as Ref + + // Verify bulk update was called with correct operations + expect(mockClient.bulk).toHaveBeenCalledWith('card', [ + { + filter: { _id: 'card1' }, + update: { + parentInfo: [ + { + _class: mockChatMasterTag, + title: 'Channel 1', + _id: 'channel1' as Ref + } + ] + } + }, + { + filter: { _id: 'card2' }, + update: { + parentInfo: [ + { + _class: mockChatMasterTag, + title: 'Channel 2', + _id: 'channel2' as Ref + } + ] + } + } + ]) + + // Verify iterator was closed + expect(mockIterator.close).toHaveBeenCalled() + + // Verify logging + expect(mockLogger.log).toHaveBeenCalledWith('Migrated cards', { count: 2 }) + }) + + it('should process multiple small batches with custom bulk size', async () => { + const batch1: Card[] = [ + createMockCard('card1', [{ _class: channelMasterTag, title: 'Channel 1', _id: 'channel1' as any }]), + createMockCard('card2', [{ _class: channelMasterTag, title: 'Channel 2', _id: 'channel2' as any }]) + ] + + const batch2: Card[] = [ + createMockCard('card3', [{ _class: channelMasterTag, title: 'Channel 3', _id: 'channel3' as any }]) + ] + + mockIterator.next.mockResolvedValueOnce(batch1).mockResolvedValueOnce(batch2).mockResolvedValueOnce([]) + + // Use small bulk size for testing + await performParentInfoMigration(mockClient, 2) + + // Verify iterator.next was called with correct bulk size + expect(mockIterator.next).toHaveBeenCalledWith(2) + + // Verify bulk was called twice + expect(mockClient.bulk).toHaveBeenCalledTimes(2) + + // Verify logging for both batches + expect(mockLogger.log).toHaveBeenNthCalledWith(1, 'Migrated cards', { count: 2 }) + expect(mockLogger.log).toHaveBeenNthCalledWith(2, 'Migrated cards', { count: 3 }) + + // Verify iterator was closed + expect(mockIterator.close).toHaveBeenCalled() + }) + + it('should skip cards without parentInfo', async () => { + const cardsWithoutParentInfo: Card[] = [createMockCard('card1', undefined), createMockCard('card2', [])] + + mockIterator.next.mockResolvedValueOnce(cardsWithoutParentInfo).mockResolvedValueOnce([]) + + await performParentInfoMigration(mockClient, 5) + + // Verify no bulk operations were performed + expect(mockClient.bulk).not.toHaveBeenCalled() + + // Verify logging shows processed cards but no updates + expect(mockLogger.log).toHaveBeenCalledWith('Migrated cards', { count: 2 }) + }) + + it('should ensure iterator is closed even if an error occurs', async () => { + mockIterator.next.mockRejectedValue(new Error('Database error')) + + await expect(performParentInfoMigration(mockClient, 10)).rejects.toThrow('Database error') + + // Verify iterator was still closed + expect(mockIterator.close).toHaveBeenCalled() + }) + + it('should use default bulk size of 1000 when not specified', async () => { + const cards: Card[] = [createMockCard('card1', [])] + + mockIterator.next.mockResolvedValueOnce(cards).mockResolvedValueOnce([]) + + await performParentInfoMigration(mockClient) + + // Verify iterator.next was called with default bulk size + expect(mockIterator.next).toHaveBeenCalledWith(1000) + }) +}) diff --git a/models/chat/src/migration.ts b/models/chat/src/migration.ts index 76a89e0972..edf25d0eaa 100644 --- a/models/chat/src/migration.ts +++ b/models/chat/src/migration.ts @@ -21,6 +21,7 @@ import { type MigrationUpgradeClient, type MigrateUpdate, type MigrationDocumentQuery, + type MigrateMode, tryMigrate } from '@hcengineering/model' import chat from './plugin' @@ -57,12 +58,16 @@ async function migrateChannelsToThreads (client: MigrationClient): Promise ) } -async function migrateParentInfo (client: MigrationClient): Promise { +async function migrateParentInfo (client: MigrationClient, mode: MigrateMode): Promise { + await performParentInfoMigration(client, 1000) +} + +export async function performParentInfoMigration (client: MigrationClient, bulkSize: number = 1000): Promise { let processedCards = 0 const iterator = await client.traverse(DOMAIN_CARD, { _class: card.class.Card }) try { while (true) { - const cards = await iterator.next(1000) + const cards = await iterator.next(bulkSize) if (cards === null || cards.length === 0) { break } diff --git a/models/chat/tsconfig.json b/models/chat/tsconfig.json index 367a8578c9..9a6f3cd438 100644 --- a/models/chat/tsconfig.json +++ b/models/chat/tsconfig.json @@ -5,7 +5,8 @@ "rootDir": "./src", "outDir": "./lib", "declarationDir": "./types", - "tsBuildInfoFile": ".build/build.tsbuildinfo" + "tsBuildInfoFile": ".build/build.tsbuildinfo", + "types": ["jest", "node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "lib", "dist", "types", "bundle"]