From 923a34c3e42e4cbd99959a7f09fa7b3260a38899 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Sun, 5 Jan 2025 11:30:57 +0100 Subject: [PATCH] test: add unit tests --- api/src/migration/migration.service.spec.ts | 458 +++++++++++++++++++ api/src/migration/migration.service.ts | 40 +- api/src/setting/services/metadata.service.ts | 35 +- 3 files changed, 514 insertions(+), 19 deletions(-) create mode 100644 api/src/migration/migration.service.spec.ts diff --git a/api/src/migration/migration.service.spec.ts b/api/src/migration/migration.service.spec.ts new file mode 100644 index 00000000..f83c23b2 --- /dev/null +++ b/api/src/migration/migration.service.spec.ts @@ -0,0 +1,458 @@ +/* + * Copyright © 2025 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { existsSync, writeFileSync } from 'fs'; + +import { HttpService } from '@nestjs/axios'; +import { ModuleRef } from '@nestjs/core'; +import { getModelToken } from '@nestjs/mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { LoggerService } from '@/logger/logger.service'; +import { MetadataService } from '@/setting/services/metadata.service'; + +import { Migration } from './migration.schema'; +import { MigrationService } from './migration.service'; +import { MigrationAction } from './types'; + +jest.mock('fs'); + +describe('MigrationService', () => { + let service: MigrationService; + let loggerService: LoggerService; + let metadataService: MetadataService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + // imports: [ + // rootMongooseTestModule(() => Promise.resolve()), + // MongooseModule.forFeature([MigrationModel]), + // ], + providers: [ + MigrationService, + { + provide: LoggerService, + useValue: { + log: jest.fn(), + error: jest.fn(), + }, + }, + { + provide: MetadataService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: HttpService, + useValue: {}, + }, + { + provide: ModuleRef, + useValue: { + get: jest.fn((token: string) => { + if (token === 'MONGO_MIGRATION_DIR') { + return '/migrations'; + } + }), + }, + }, + { + provide: getModelToken(Migration.name), + useValue: jest.fn(), + }, + ], + }).compile(); + + service = module.get(MigrationService); + loggerService = module.get(LoggerService); + metadataService = module.get(MetadataService); + + jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination + }); + + describe('validateMigrationPath', () => { + it('should log an error and exit if the migration path does not exist', () => { + (existsSync as jest.Mock).mockReturnValue(false); + const exitSpy = jest.spyOn(service, 'exit').mockImplementation(); + + service.validateMigrationPath(); + + expect(loggerService.error).toHaveBeenCalledWith( + 'Migration directory "/migrations" not exists.', + ); + expect(exitSpy).toHaveBeenCalled(); + }); + + it('should not log an error or exit if the migration path exists', () => { + (existsSync as jest.Mock).mockReturnValue(true); + const exitSpy = jest.spyOn(service, 'exit').mockImplementation(); + + service.validateMigrationPath(); + + expect(loggerService.error).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('create', () => { + it('should create a migration file and log success', () => { + const mockFiles = ['12345-some-migration.migration.ts']; + jest.spyOn(service, 'getMigrationFiles').mockReturnValue(mockFiles); + jest + .spyOn(service as any, 'getMigrationTemplate') + .mockReturnValue('template'); + jest + .spyOn(service as any, 'getMigrationName') + .mockImplementation((file) => file); + const exitSpy = jest.spyOn(service, 'exit').mockImplementation(); + + (existsSync as jest.Mock).mockReturnValue(true); + (writeFileSync as jest.Mock).mockImplementation(); + + service.create('v2.2.0'); + + const expectedFilePath = expect.stringMatching( + /\/migrations\/\d+-v-2-2-0.migration.ts$/, + ); + expect(writeFileSync).toHaveBeenCalledWith(expectedFilePath, 'template'); + expect(loggerService.log).toHaveBeenCalledWith( + expect.stringMatching(/Migration file for "v2.2.0" created/), + ); + expect(exitSpy).toHaveBeenCalled(); + }); + + it('should log an error and exit if a migration with the same name exists', () => { + const mockFiles = ['12345-v-2-2-1.migration.ts']; + jest.spyOn(service, 'getMigrationFiles').mockReturnValue(mockFiles); + const exitSpy = jest.spyOn(service, 'exit').mockImplementation(); + + service.create('v2.2.1'); + + expect(loggerService.error).toHaveBeenCalledWith( + 'Migration file for "v2.2.1" already exists', + ); + expect(exitSpy).toHaveBeenCalled(); + }); + }); + + describe('onApplicationBootstrap', () => { + it('should log a message and execute migrations when autoMigrate is true', async () => { + process.env.HEXABOT_CLI = ''; + jest + .spyOn(metadataService, 'get') + .mockResolvedValue({ name: 'db-version', value: 'v2.1.9' }); + jest.spyOn(service, 'run').mockResolvedValue(); + + await service.onApplicationBootstrap(); + + expect(loggerService.log).toHaveBeenCalledWith( + 'Executing migrations ...', + ); + expect(service.run).toHaveBeenCalledWith({ + action: 'up', + version: 'v2.1.9', + isAutoMigrate: true, + }); + }); + }); + + describe('run', () => { + it('should call runUpgrades when version is not provided and isAutoMigrate is true', async () => { + const runUpgradesSpy = jest + .spyOn(service as any, 'runUpgrades') + .mockResolvedValue('v2.2.0'); + + await service.run({ + action: MigrationAction.UP, + version: null, + isAutoMigrate: true, + }); + + expect(runUpgradesSpy).toHaveBeenCalledWith('up', null); + expect(service.exit).not.toHaveBeenCalled(); + }); + + it('should call runAll and exit when version is not provided and isAutoMigrate is false', async () => { + const runAllSpy = jest + .spyOn(service as any, 'runAll') + .mockResolvedValue('v2.2.0'); + + await service.run({ + action: MigrationAction.UP, + version: null, + isAutoMigrate: false, + }); + + expect(runAllSpy).toHaveBeenCalledWith('up'); + expect(service.exit).toHaveBeenCalled(); + }); + + it('should call runOne and exit when version is provided', async () => { + const runOneSpy = jest + .spyOn(service as any, 'runOne') + .mockResolvedValue('v2.2.0'); + + await service.run({ + action: MigrationAction.UP, + version: 'v2.1.9', + isAutoMigrate: false, + }); + + expect(runOneSpy).toHaveBeenCalledWith({ + action: 'up', + version: 'v2.1.9', + }); + expect(service.exit).toHaveBeenCalled(); + }); + }); + + describe('runOne', () => { + let verifyStatusSpy: jest.SpyInstance; + let loadMigrationFileSpy: jest.SpyInstance; + let successCallbackSpy: jest.SpyInstance; + let failureCallbackSpy: jest.SpyInstance; + + beforeEach(() => { + verifyStatusSpy = jest + .spyOn(service as any, 'verifyStatus') + .mockResolvedValue({ exist: false, migrationDocument: {} }); + loadMigrationFileSpy = jest + .spyOn(service as any, 'loadMigrationFile') + .mockResolvedValue({ + up: jest.fn().mockResolvedValue(true), + down: jest.fn().mockResolvedValue(true), + }); + successCallbackSpy = jest + .spyOn(service as any, 'successCallback') + .mockResolvedValue(undefined); + failureCallbackSpy = jest + .spyOn(service as any, 'failureCallback') + .mockResolvedValue(undefined); + }); + + afterEach(() => { + verifyStatusSpy.mockClear(); + loadMigrationFileSpy.mockClear(); + successCallbackSpy.mockClear(); + failureCallbackSpy.mockClear(); + }); + + it('should return false and not execute if migration already exists', async () => { + verifyStatusSpy.mockResolvedValue({ exist: true, migrationDocument: {} }); + + const result = await (service as any).runOne({ + version: 'v2.1.9', + action: 'up', + }); + + expect(verifyStatusSpy).toHaveBeenCalledWith({ + version: 'v2.1.9', + action: 'up', + }); + expect(result).toBe(false); + expect(loadMigrationFileSpy).not.toHaveBeenCalled(); + expect(successCallbackSpy).not.toHaveBeenCalled(); + expect(failureCallbackSpy).not.toHaveBeenCalled(); + }); + + it('should load the migration file and execute the migration action successfully', async () => { + const migrationMock = { + up: jest.fn().mockResolvedValue(true), + }; + loadMigrationFileSpy.mockResolvedValue(migrationMock); + + const result = await (service as any).runOne({ + version: 'v2.1.9', + action: 'up', + }); + + expect(result).toBe(true); + expect(verifyStatusSpy).toHaveBeenCalledWith({ + version: 'v2.1.9', + action: 'up', + }); + expect(loadMigrationFileSpy).toHaveBeenCalledWith('v2.1.9'); + expect(migrationMock.up).toHaveBeenCalledWith({ + logger: service['logger'], + http: service['httpService'], + }); + expect(successCallbackSpy).toHaveBeenCalledWith({ + version: 'v2.1.9', + action: 'up', + migrationDocument: {}, + }); + expect(failureCallbackSpy).not.toHaveBeenCalled(); + }); + + it('should call failureCallback and log the error if the migration action throws an error', async () => { + const migrationMock = { + up: jest.fn().mockRejectedValue(new Error('Test Error')), + }; + loadMigrationFileSpy.mockResolvedValue(migrationMock); + const loggerSpy = jest.spyOn(service['logger'], 'log'); + + const result = await (service as any).runOne({ + version: 'v2.1.9', + action: 'up', + }); + expect(result).toBe(false); + + expect(verifyStatusSpy).toHaveBeenCalledWith({ + version: 'v2.1.9', + action: 'up', + }); + expect(loadMigrationFileSpy).toHaveBeenCalledWith('v2.1.9'); + expect(migrationMock.up).toHaveBeenCalledWith({ + logger: service['logger'], + http: service['httpService'], + }); + expect(successCallbackSpy).not.toHaveBeenCalled(); + expect(failureCallbackSpy).toHaveBeenCalledWith({ + version: 'v2.1.9', + action: 'up', + }); + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('Test Error'), + ); + }); + + it('should not call successCallback if the migration action returns false', async () => { + const migrationMock = { + up: jest.fn().mockResolvedValue(false), + }; + loadMigrationFileSpy.mockResolvedValue(migrationMock); + + const result = await (service as any).runOne({ + version: 'v2.1.9', + action: 'up', + }); + expect(result).toBe(false); + expect(verifyStatusSpy).toHaveBeenCalledWith({ + version: 'v2.1.9', + action: 'up', + }); + expect(loadMigrationFileSpy).toHaveBeenCalledWith('v2.1.9'); + expect(migrationMock.up).toHaveBeenCalledWith({ + logger: service['logger'], + http: service['httpService'], + }); + expect(successCallbackSpy).not.toHaveBeenCalled(); + expect(failureCallbackSpy).not.toHaveBeenCalled(); + }); + }); + + describe('runUpgrades', () => { + let getAvailableUpgradeVersionsSpy: jest.SpyInstance; + let isNewerVersionSpy: jest.SpyInstance; + let runOneSpy: jest.SpyInstance; + + beforeEach(() => { + getAvailableUpgradeVersionsSpy = jest + .spyOn(service as any, 'getAvailableUpgradeVersions') + .mockReturnValue(['v2.2.0', 'v2.3.0', 'v2.4.0']); // Mock available versions + isNewerVersionSpy = jest + .spyOn(service as any, 'isNewerVersion') + .mockImplementation( + (v1: string, v2: string) => + parseInt(v1.substring(1).replaceAll('.', '')) > + parseInt(v2.substring(1).replaceAll('.', '')), + ); // Simplified mock for version comparison + runOneSpy = jest.spyOn(service as any, 'runOne').mockResolvedValue(true); + }); + + it('should filter versions and call runOne for each newer version', async () => { + const result = await (service as any).runUpgrades('up', 'v2.2.0'); + + expect(getAvailableUpgradeVersionsSpy).toHaveBeenCalled(); + expect(runOneSpy).toHaveBeenCalledTimes(2); // Only for 'v2.3.0' and 'v2.4.0' + expect(runOneSpy).toHaveBeenCalledWith({ + version: 'v2.3.0', + action: 'up', + }); + expect(runOneSpy).toHaveBeenCalledWith({ + version: 'v2.4.0', + action: 'up', + }); + expect(result).toBe('v2.4.0'); // Last processed version + }); + + it('should return the initial version if no newer versions are available', async () => { + isNewerVersionSpy.mockImplementation(() => false); // Mock to return no newer versions + + const result = await (service as any).runUpgrades('up', 'v2.4.0'); + + expect(getAvailableUpgradeVersionsSpy).toHaveBeenCalled(); + expect(isNewerVersionSpy).toHaveBeenCalledTimes(3); + expect(runOneSpy).not.toHaveBeenCalled(); + expect(result).toBe('v2.4.0'); // Initial version is returned + }); + + it('should handle empty available versions gracefully', async () => { + getAvailableUpgradeVersionsSpy.mockReturnValue([]); + + const result = await (service as any).runUpgrades('up', 'v2.2.0'); + + expect(getAvailableUpgradeVersionsSpy).toHaveBeenCalled(); + expect(isNewerVersionSpy).not.toHaveBeenCalled(); + expect(runOneSpy).not.toHaveBeenCalled(); + expect(result).toBe('v2.2.0'); // Initial version is returned + }); + + it('should propagate errors from runOne', async () => { + runOneSpy.mockRejectedValue(new Error('Test Error')); + + await expect( + (service as any).runUpgrades('up', 'v2.2.0'), + ).rejects.toThrow('Test Error'); + + expect(getAvailableUpgradeVersionsSpy).toHaveBeenCalled(); + expect(isNewerVersionSpy).toHaveBeenCalled(); + expect(runOneSpy).toHaveBeenCalledWith({ + version: 'v2.3.0', + action: 'up', + }); + }); + }); + + it('should return the migration name without the timestamp and file extension', () => { + const result = (service as any).getMigrationName( + '1234567890-v-1-0-1.migration.ts', + ); + + expect(result).toBe('v-1-0-1'); + }); + + it('should load a valid migration file and return it', async () => { + const version = 'v2.1.9'; + + const mockFiles = ['1234567890-v-2-1-9.migration.js']; + jest.spyOn(service, 'getMigrationFiles').mockReturnValue(mockFiles); + const mockMigration = { + up: jest.fn(), + down: jest.fn(), + }; + + jest + .spyOn(service, 'migrationFilePath', 'get') + .mockReturnValue('/migrations'); + jest.spyOn(service['logger'], 'error').mockImplementation(); + jest.mock( + `/migrations/1234567890-v-2-1-9.migration.js`, + () => mockMigration, + { + virtual: true, + }, + ); + + const result = await (service as any).loadMigrationFile(version); + + expect(result.default).toBe(mockMigration); + expect(service['logger'].error).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/migration/migration.service.ts b/api/src/migration/migration.service.ts index 3ddb8fa4..9eaf2ff0 100644 --- a/api/src/migration/migration.service.ts +++ b/api/src/migration/migration.service.ts @@ -33,6 +33,9 @@ import { MigrationVersion, } from './types'; +// Version starting which we added the migrations +const INITIAL_DB_VERSION = 'v2.1.9'; + @Injectable() export class MigrationService implements OnApplicationBootstrap { constructor( @@ -43,7 +46,9 @@ export class MigrationService implements OnApplicationBootstrap { @InjectModel(Migration.name) private readonly migrationModel: Model, ) { - this.validateMigrationPath(); + if (config.env !== 'test') { + this.validateMigrationPath(); + } } async onApplicationBootstrap() { @@ -55,8 +60,8 @@ export class MigrationService implements OnApplicationBootstrap { const isCLI = Boolean(process.env.HEXABOT_CLI); if (!isCLI && config.mongo.autoMigrate) { this.logger.log('Executing migrations ...'); - const metadata = await this.metadataService.getMetadata('db-version'); - const version = metadata ? metadata.value : 'v2.1.9'; + const metadata = await this.metadataService.get('db-version'); + const version = metadata ? metadata.value : INITIAL_DB_VERSION; await this.run({ action: MigrationAction.UP, version, @@ -101,13 +106,12 @@ export class MigrationService implements OnApplicationBootstrap { * @throws If there is an issue writing the migration file. */ public create(version: MigrationVersion) { - const fileName: string = kebabCase(version) + '.migration.ts'; - + const name = kebabCase(version) as MigrationName; // check if file already exists const files = this.getMigrationFiles(); const exist = files.some((file) => { const migrationName = this.getMigrationName(file); - return migrationName === fileName; + return migrationName === name; }); if (exist) { @@ -115,7 +119,7 @@ export class MigrationService implements OnApplicationBootstrap { this.exit(); } - const migrationFileName = `${Date.now()}-${fileName}`; + const migrationFileName = `${Date.now()}-${name}.migration.ts`; const filePath = join(this.migrationFilePath, migrationFileName); const template = this.getMigrationTemplate(); try { @@ -155,6 +159,11 @@ module.exports = { * Establishes a MongoDB connection */ private async connect() { + // Disable for unit tests + if (config.env === 'test') { + return; + } + try { const connection = await mongoose.connect(config.mongo.uri, { dbName: config.mongo.dbName, @@ -217,7 +226,7 @@ module.exports = { }); if (exist) { - return true; // stop exec; + return false; // stop exec; } try { @@ -226,6 +235,7 @@ module.exports = { logger: this.logger, http: this.httpService, }); + if (result) { await this.successCallback({ version, @@ -233,12 +243,15 @@ module.exports = { migrationDocument, }); } + + return result; // stop exec; } catch (e) { this.failureCallback({ version, action, }); this.logger.log(e.stack); + return false; } } @@ -309,9 +322,13 @@ module.exports = { private async runAll(action: MigrationAction) { const versions = this.getAvailableUpgradeVersions(); + let lastVersion: MigrationVersion = INITIAL_DB_VERSION; for (const version of versions) { await this.runOne({ version, action }); + lastVersion = version; } + + return lastVersion; } /** @@ -399,9 +416,10 @@ module.exports = { const files = this.getMigrationFiles(); const migrationName = kebabCase(version) as MigrationName; return ( - files - .map((file) => this.getMigrationName(file)) - .find((name) => migrationName === name) || null + files.find((file) => { + const name = this.getMigrationName(file); + return migrationName === name; + }) || null ); } diff --git a/api/src/setting/services/metadata.service.ts b/api/src/setting/services/metadata.service.ts index cbea67cd..07b2acc6 100644 --- a/api/src/setting/services/metadata.service.ts +++ b/api/src/setting/services/metadata.service.ts @@ -8,7 +8,8 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Model } from 'mongoose'; +import { plainToClass } from 'class-transformer'; +import { HydratedDocument, Model } from 'mongoose'; import { Metadata } from '../schemas/metadata.schema'; @@ -19,17 +20,35 @@ export class MetadataService { private readonly metadataModel: Model, ) {} + private toClassObject(metadata: HydratedDocument) { + return plainToClass( + Metadata, + metadata.toObject({ virtuals: true, getters: true }), + { excludePrefixes: ['_'] }, + ); + } + async createOrUpdate(dto: Metadata) { - return await this.metadataModel.findOneAndUpdate({ name: dto.name }, dto, { - upsert: true, - }); + const metadata = await this.metadataModel.findOneAndUpdate( + { name: dto.name }, + dto, + { + upsert: true, + }, + ); + return this.toClassObject(metadata); } - async getMetadata(name: string) { - return await this.metadataModel.findOne({ name }); + async get(name: string) { + const metadata = await this.metadataModel.findOne({ name }); + return this.toClassObject(metadata); } - async setMetadata(name: string, value: any) { - return await this.metadataModel.updateOne({ name }, { $set: { value } }); + async set(name: string, value: any) { + const metadata = await this.metadataModel.findOneAndUpdate( + { name }, + { $set: { value } }, + ); + return this.toClassObject(metadata); } }