test: add unit tests

This commit is contained in:
Mohamed Marrouchi 2025-01-05 11:30:57 +01:00
parent 252857fbf7
commit 923a34c3e4
3 changed files with 514 additions and 19 deletions

View File

@ -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>(MigrationService);
loggerService = module.get<LoggerService>(LoggerService);
metadataService = module.get<MetadataService>(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();
});
});

View File

@ -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<Migration>,
) {
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
);
}

View File

@ -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<Metadata>,
) {}
private toClassObject(metadata: HydratedDocument<Metadata>) {
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);
}
}