feat: add metadata repo + seeder

This commit is contained in:
Mohamed Marrouchi 2025-01-05 21:51:29 +01:00
parent 923a34c3e4
commit ef788591bf
15 changed files with 225 additions and 138 deletions

View File

@ -6,35 +6,43 @@
* 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 fs from 'fs';
import { HttpService } from '@nestjs/axios';
import { ModuleRef } from '@nestjs/core';
import { getModelToken } from '@nestjs/mongoose';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { getModelToken, MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { LoggerService } from '@/logger/logger.service';
import { MetadataRepository } from '@/setting/repositories/metadata.repository';
import { Metadata, MetadataModel } from '@/setting/schemas/metadata.schema';
import { MetadataService } from '@/setting/services/metadata.service';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { Migration } from './migration.schema';
import { Migration, MigrationModel } 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 () => {
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
// imports: [
// rootMongooseTestModule(() => Promise.resolve()),
// MongooseModule.forFeature([MigrationModel]),
// ],
imports: [
rootMongooseTestModule(async () => await Promise.resolve()),
MongooseModule.forFeature([MetadataModel, MigrationModel]),
],
providers: [
MetadataRepository,
MetadataService,
MigrationService,
EventEmitter2,
{
provide: LoggerService,
useValue: {
@ -42,12 +50,6 @@ describe('MigrationService', () => {
error: jest.fn(),
},
},
{
provide: MetadataService,
useValue: {
get: jest.fn(),
},
},
{
provide: HttpService,
useValue: {},
@ -72,35 +74,18 @@ describe('MigrationService', () => {
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();
});
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('create', () => {
beforeEach(() => {
jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination
});
afterEach(jest.restoreAllMocks);
it('should create a migration file and log success', () => {
const mockFiles = ['12345-some-migration.migration.ts'];
jest.spyOn(service, 'getMigrationFiles').mockReturnValue(mockFiles);
@ -112,15 +97,18 @@ describe('MigrationService', () => {
.mockImplementation((file) => file);
const exitSpy = jest.spyOn(service, 'exit').mockImplementation();
(existsSync as jest.Mock).mockReturnValue(true);
(writeFileSync as jest.Mock).mockImplementation();
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'writeFileSync').mockReturnValue();
service.create('v2.2.0');
const expectedFilePath = expect.stringMatching(
/\/migrations\/\d+-v-2-2-0.migration.ts$/,
);
expect(writeFileSync).toHaveBeenCalledWith(expectedFilePath, 'template');
expect(fs.writeFileSync).toHaveBeenCalledWith(
expectedFilePath,
'template',
);
expect(loggerService.log).toHaveBeenCalledWith(
expect.stringMatching(/Migration file for "v2.2.0" created/),
);
@ -142,11 +130,17 @@ describe('MigrationService', () => {
});
describe('onApplicationBootstrap', () => {
beforeEach(() => {
jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination
});
afterEach(jest.restoreAllMocks);
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' });
.spyOn(metadataService, 'findOne')
.mockResolvedValue({ name: 'db-version', value: 'v2.1.9' } as Metadata);
jest.spyOn(service, 'run').mockResolvedValue();
await service.onApplicationBootstrap();
@ -156,13 +150,18 @@ describe('MigrationService', () => {
);
expect(service.run).toHaveBeenCalledWith({
action: 'up',
version: 'v2.1.9',
isAutoMigrate: true,
});
});
});
describe('run', () => {
beforeEach(() => {
jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination
});
afterEach(jest.restoreAllMocks);
it('should call runUpgrades when version is not provided and isAutoMigrate is true', async () => {
const runUpgradesSpy = jest
.spyOn(service as any, 'runUpgrades')
@ -170,11 +169,10 @@ describe('MigrationService', () => {
await service.run({
action: MigrationAction.UP,
version: null,
isAutoMigrate: true,
});
expect(runUpgradesSpy).toHaveBeenCalledWith('up', null);
expect(runUpgradesSpy).toHaveBeenCalledWith('up', 'v2.1.9');
expect(service.exit).not.toHaveBeenCalled();
});
@ -219,6 +217,8 @@ describe('MigrationService', () => {
let failureCallbackSpy: jest.SpyInstance;
beforeEach(() => {
jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination
verifyStatusSpy = jest
.spyOn(service as any, 'verifyStatus')
.mockResolvedValue({ exist: false, migrationDocument: {} });
@ -236,12 +236,7 @@ describe('MigrationService', () => {
.mockResolvedValue(undefined);
});
afterEach(() => {
verifyStatusSpy.mockClear();
loadMigrationFileSpy.mockClear();
successCallbackSpy.mockClear();
failureCallbackSpy.mockClear();
});
afterEach(jest.restoreAllMocks);
it('should return false and not execute if migration already exists', async () => {
verifyStatusSpy.mockResolvedValue({ exist: true, migrationDocument: {} });
@ -353,6 +348,8 @@ describe('MigrationService', () => {
let runOneSpy: jest.SpyInstance;
beforeEach(() => {
jest.spyOn(service, 'exit').mockImplementation(); // Mock exit to avoid Jest process termination
getAvailableUpgradeVersionsSpy = jest
.spyOn(service as any, 'getAvailableUpgradeVersions')
.mockReturnValue(['v2.2.0', 'v2.3.0', 'v2.4.0']); // Mock available versions
@ -366,6 +363,8 @@ describe('MigrationService', () => {
runOneSpy = jest.spyOn(service as any, 'runOne').mockResolvedValue(true);
});
afterEach(jest.restoreAllMocks);
it('should filter versions and call runOne for each newer version', async () => {
const result = await (service as any).runUpgrades('up', 'v2.2.0');

View File

@ -6,7 +6,7 @@
* 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, readdirSync, writeFileSync } from 'fs';
import fs from 'fs';
import { join } from 'path';
import { HttpService } from '@nestjs/axios';
@ -45,13 +45,11 @@ export class MigrationService implements OnApplicationBootstrap {
private readonly httpService: HttpService,
@InjectModel(Migration.name)
private readonly migrationModel: Model<Migration>,
) {
if (config.env !== 'test') {
this.validateMigrationPath();
}
}
) {}
async onApplicationBootstrap() {
await this.ensureMigrationPathExists();
if (mongoose.connection.readyState !== 1) {
await this.connect();
}
@ -60,11 +58,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.get('db-version');
const version = metadata ? metadata.value : INITIAL_DB_VERSION;
await this.run({
action: MigrationAction.UP,
version,
isAutoMigrate: true,
});
}
@ -85,12 +80,11 @@ export class MigrationService implements OnApplicationBootstrap {
/**
* Checks if the migration path is well set and exists
*/
public validateMigrationPath() {
if (!existsSync(this.migrationFilePath)) {
this.logger.error(
`Migration directory "${this.migrationFilePath}" not exists.`,
);
this.exit();
private async ensureMigrationPathExists() {
if (config.env !== 'test' && !fs.existsSync(this.migrationFilePath)) {
await fs.promises.mkdir(this.migrationFilePath, {
recursive: true,
});
}
}
@ -123,7 +117,7 @@ export class MigrationService implements OnApplicationBootstrap {
const filePath = join(this.migrationFilePath, migrationFileName);
const template = this.getMigrationTemplate();
try {
writeFileSync(filePath, template);
fs.writeFileSync(filePath, template);
this.logger.log(
`Migration file for "${version}" created: ${migrationFileName}`,
);
@ -195,6 +189,10 @@ module.exports = {
public async run({ action, version, isAutoMigrate }: MigrationRunParams) {
if (!version) {
if (isAutoMigrate) {
const metadata = await this.metadataService.findOne({
name: 'db-version',
});
const version = metadata ? metadata.value : INITIAL_DB_VERSION;
await this.runUpgrades(action, version);
} else {
await this.runAll(action);
@ -302,6 +300,12 @@ module.exports = {
const filteredVersions = versions.filter((v) =>
this.isNewerVersion(v, version),
);
if (!filteredVersions.length) {
this.logger.log('No migrations to execute ...');
return version;
}
let lastVersion = version;
for (const version of filteredVersions) {
@ -371,7 +375,7 @@ module.exports = {
* @returns A promise resolving to an array of migration file names.
*/
getMigrationFiles() {
const files = readdirSync(this.migrationFilePath);
const files = fs.readdirSync(this.migrationFilePath);
return files.filter((file) => /\.migration\.(js|ts)$/.test(file));
}
@ -496,13 +500,19 @@ module.exports = {
await this.updateStatus({ version, action, migrationDocument });
const migrationDisplayName = `${version} [${action}]`;
this.logger.log(`"${migrationDisplayName}" migration done`);
// Update DB version
const result = await this.metadataService.createOrUpdate({
name: 'db-version',
value: version,
});
const operation = result ? 'updated' : 'created';
this.logger.log(`db-version metadata ${operation} "${name}"`);
// Create or Update DB version
await this.metadataService.updateOne(
{ name: 'db-version' },
{
value: version,
},
{
// Create or update
upsert: true,
new: true,
},
);
this.logger.log(`db-version metadata "${version}"`);
}
/**

View File

@ -6,6 +6,10 @@
* 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 { HttpService } from '@nestjs/axios';
import { LoggerService } from '@/logger/logger.service';
import { MigrationDocument } from './migration.schema';
export enum MigrationAction {
@ -26,3 +30,8 @@ export interface MigrationRunParams {
export interface MigrationSuccessCallback extends MigrationRunParams {
migrationDocument: MigrationDocument;
}
export type MigrationServices = {
logger: LoggerService;
http: HttpService;
};

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* 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.
@ -21,6 +21,8 @@ import { NlpEntitySeeder } from './nlp/seeds/nlp-entity.seed';
import { nlpEntityModels } from './nlp/seeds/nlp-entity.seed-model';
import { NlpValueSeeder } from './nlp/seeds/nlp-value.seed';
import { nlpValueModels } from './nlp/seeds/nlp-value.seed-model';
import { MetadataSeeder } from './setting/seeds/metadata.seed';
import { DEFAULT_METADATA } from './setting/seeds/metadata.seed-model';
import { SettingSeeder } from './setting/seeds/setting.seed';
import { DEFAULT_SETTINGS } from './setting/seeds/setting.seed-model';
import { PermissionCreateDto } from './user/dto/permission.dto';
@ -41,6 +43,7 @@ export async function seedDatabase(app: INestApplicationContext) {
const contextVarSeeder = app.get(ContextVarSeeder);
const roleSeeder = app.get(RoleSeeder);
const settingSeeder = app.get(SettingSeeder);
const metadataSeeder = app.get(MetadataSeeder);
const permissionSeeder = app.get(PermissionSeeder);
const userSeeder = app.get(UserSeeder);
const languageSeeder = app.get(LanguageSeeder);
@ -106,13 +109,15 @@ export async function seedDatabase(app: INestApplicationContext) {
logger.error('Unable to seed the database with users!');
throw e;
}
// Seed users
try {
await settingSeeder.seed(DEFAULT_SETTINGS);
} catch (e) {
logger.error('Unable to seed the database with settings!');
throw e;
}
}
// Seed settings and metadata
try {
await settingSeeder.seed(DEFAULT_SETTINGS);
await metadataSeeder.seed(DEFAULT_METADATA);
} catch (e) {
logger.error('Unable to seed the database with settings and metadata!');
throw e;
}
// Seed categories

View File

@ -0,0 +1,23 @@
/*
* 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 { ApiProperty, PartialType } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class MetadataCreateDto {
@ApiProperty({ description: 'Metadata name', type: String })
@IsNotEmpty()
@IsString()
name: string;
@ApiProperty({ description: 'Metadata value' })
@IsNotEmpty()
value: any;
}
export class MetadataUpdateDto extends PartialType(MetadataCreateDto) {}

View File

@ -0,0 +1,26 @@
/*
* 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 { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { BaseRepository } from '@/utils/generics/base-repository';
import { Metadata } from '../schemas/metadata.schema';
@Injectable()
export class MetadataRepository extends BaseRepository<Metadata> {
constructor(
readonly eventEmitter: EventEmitter2,
@InjectModel(Metadata.name) readonly model: Model<Metadata>,
) {
super(eventEmitter, model, Metadata);
}
}

View File

@ -9,10 +9,11 @@
import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
@Schema({ timestamps: true })
export class Metadata {
export class Metadata extends BaseSchema {
@Prop({ type: String, required: true, unique: true })
name: string;

View File

@ -0,0 +1,16 @@
/*
* 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 { MetadataCreateDto } from '../dto/metadata.dto';
export const DEFAULT_METADATA = [
{
name: 'db-version',
value: process.env.npm_package_version,
},
] as const satisfies MetadataCreateDto[];

View File

@ -0,0 +1,21 @@
/*
* 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 { Injectable } from '@nestjs/common';
import { BaseSeeder } from '@/utils/generics/base-seeder';
import { MetadataRepository } from '../repositories/metadata.repository';
import { Metadata } from '../schemas/metadata.schema';
@Injectable()
export class MetadataSeeder extends BaseSeeder<Metadata> {
constructor(private readonly metadataRepository: MetadataRepository) {
super(metadataRepository);
}
}

View File

@ -7,48 +7,15 @@
*/
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { plainToClass } from 'class-transformer';
import { HydratedDocument, Model } from 'mongoose';
import { BaseService } from '@/utils/generics/base-service';
import { MetadataRepository } from '../repositories/metadata.repository';
import { Metadata } from '../schemas/metadata.schema';
@Injectable()
export class MetadataService {
constructor(
@InjectModel(Metadata.name)
private readonly metadataModel: Model<Metadata>,
) {}
private toClassObject(metadata: HydratedDocument<Metadata>) {
return plainToClass(
Metadata,
metadata.toObject({ virtuals: true, getters: true }),
{ excludePrefixes: ['_'] },
);
}
async createOrUpdate(dto: Metadata) {
const metadata = await this.metadataModel.findOneAndUpdate(
{ name: dto.name },
dto,
{
upsert: true,
},
);
return this.toClassObject(metadata);
}
async get(name: string) {
const metadata = await this.metadataModel.findOne({ name });
return this.toClassObject(metadata);
}
async set(name: string, value: any) {
const metadata = await this.metadataModel.findOneAndUpdate(
{ name },
{ $set: { value } },
);
return this.toClassObject(metadata);
export class MetadataService extends BaseService<Metadata> {
constructor(readonly repository: MetadataRepository) {
super(repository);
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* 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.
@ -11,9 +11,11 @@ import { MongooseModule } from '@nestjs/mongoose';
import { PassportModule } from '@nestjs/passport';
import { SettingController } from './controllers/setting.controller';
import { MetadataRepository } from './repositories/metadata.repository';
import { SettingRepository } from './repositories/setting.repository';
import { MetadataModel } from './schemas/metadata.schema';
import { SettingModel } from './schemas/setting.schema';
import { MetadataSeeder } from './seeds/metadata.seed';
import { SettingSeeder } from './seeds/setting.seed';
import { MetadataService } from './services/metadata.service';
import { SettingService } from './services/setting.service';
@ -28,7 +30,9 @@ import { SettingService } from './services/setting.service';
],
providers: [
SettingRepository,
MetadataRepository,
SettingSeeder,
MetadataSeeder,
SettingService,
MetadataService,
],

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* 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.
@ -20,6 +20,7 @@ import {
Model,
ProjectionType,
Query,
QueryOptions,
SortOrder,
UpdateQuery,
UpdateWithAggregationPipeline,
@ -474,6 +475,9 @@ export abstract class BaseRepository<
async updateOne<D extends Partial<U>>(
criteria: string | TFilterQuery<T>,
dto: UpdateQuery<D>,
options: QueryOptions<D> | null = {
new: true,
},
): Promise<T | null> {
const query = this.model.findOneAndUpdate<T>(
{
@ -482,9 +486,7 @@ export abstract class BaseRepository<
{
$set: dto,
},
{
new: true,
},
options,
);
const filterCriteria = query.getFilter();
const queryUpdates = query.getUpdate();

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* 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.
@ -84,6 +84,7 @@ describe('BaseService', () => {
expect(dummyRepository.updateOne).toHaveBeenCalledWith(
createdId,
updatedPayload,
undefined,
);
expect(result).toEqualPayload(updatedPayload);
});
@ -98,6 +99,7 @@ describe('BaseService', () => {
expect(dummyRepository.updateOne).toHaveBeenCalledWith(
updatedPayload,
updatedCriteriaPayload,
undefined,
);
expect(result).toEqualPayload(updatedCriteriaPayload);
});

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* 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.
@ -9,7 +9,7 @@
import { ConflictException } from '@nestjs/common';
import { ClassTransformOptions } from 'class-transformer';
import { MongoError } from 'mongodb';
import { ProjectionType } from 'mongoose';
import { ProjectionType, QueryOptions } from 'mongoose';
import { TFilterQuery } from '@/utils/types/filter.types';
@ -173,8 +173,9 @@ export abstract class BaseService<
async updateOne<D extends Partial<Omit<T, keyof BaseSchema>>>(
criteria: string | TFilterQuery<T>,
dto: D,
options?: QueryOptions<D> | null,
): Promise<T | null> {
return await this.repository.updateOne(criteria, dto);
return await this.repository.updateOne(criteria, dto, options);
}
async updateMany<D extends Partial<Omit<T, keyof BaseSchema>>>(

View File

@ -8,9 +8,10 @@
import mongoose from 'mongoose';
import { Metadata, MetadataModel } from '@/setting/schemas/metadata.schema';
import { MetadataCreateDto } from '@/setting/dto/metadata.dto';
import { MetadataModel } from '@/setting/schemas/metadata.schema';
const metadataFixtures: Metadata[] = [
const metadataFixtures: MetadataCreateDto[] = [
{
name: 'app-version',
value: '2.2.0',