diff --git a/api/src/setting/repositories/setting.repository.spec.ts b/api/src/setting/repositories/setting.repository.spec.ts new file mode 100644 index 00000000..f471483f --- /dev/null +++ b/api/src/setting/repositories/setting.repository.spec.ts @@ -0,0 +1,186 @@ +/* + * Copyright © 2024 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 { EventEmitter2 } from '@nestjs/event-emitter'; +import { getModelToken, MongooseModule } from '@nestjs/mongoose'; +import { Test } from '@nestjs/testing'; +import { Model } from 'mongoose'; + +import { installSettingFixtures } from '@/utils/test/fixtures/setting'; +import { + closeInMongodConnection, + rootMongooseTestModule, +} from '@/utils/test/test'; + +import { Setting, SettingModel } from '../schemas/setting.schema'; +import { SettingType } from '../schemas/types'; + +import { SettingRepository } from './setting.repository'; + +describe('SettingRepository', () => { + let settingRepository: SettingRepository; + let settingModel: Model; + let eventEmitter: EventEmitter2; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [ + rootMongooseTestModule(installSettingFixtures), + MongooseModule.forFeature([SettingModel]), + ], + providers: [SettingRepository, EventEmitter2], + }).compile(); + + settingRepository = module.get(SettingRepository); + settingModel = module.get>(getModelToken(Setting.name)); + eventEmitter = module.get(EventEmitter2); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(closeInMongodConnection); + + describe('preCreateValidate', () => { + it('should validate setting value during creation', async () => { + const mockSetting = new settingModel({ + type: SettingType.text, + value: 'Sample Text', + }); + jest.spyOn(settingRepository, 'validateSettingValue'); + + await settingRepository.preCreateValidate(mockSetting); + + expect(settingRepository['validateSettingValue']).toHaveBeenCalledWith( + SettingType.text, + 'Sample Text', + ); + }); + + it('should throw an error for invalid value type', async () => { + const mockSetting = new settingModel({ + type: SettingType.checkbox, + value: 'Invalid Value', + }); + + await expect( + settingRepository.preCreateValidate(mockSetting), + ).rejects.toThrow('Setting Model : Value must be a boolean!'); + }); + }); + + describe('preUpdateValidate', () => { + it('should validate updated setting value', async () => { + const criteria = { _id: '123' }; + const updates = { + $set: { value: 'Updated Text' }, + }; + + jest.spyOn(settingRepository, 'findOne').mockResolvedValue({ + type: SettingType.text, + } as any); + + await settingRepository.preUpdateValidate(criteria, updates); + + expect(settingRepository.findOne).toHaveBeenCalledWith(criteria); + expect(settingRepository['validateSettingValue']).toHaveBeenCalledWith( + SettingType.text, + 'Updated Text', + ); + }); + }); + + describe('postUpdate', () => { + it('should emit an event after updating a setting', async () => { + const mockSetting = new settingModel({ + group: 'general', + label: 'theme', + }); + + jest.spyOn(eventEmitter, 'emit'); + + await settingRepository.postUpdate({} as any, mockSetting); + + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'hook:general:theme', + mockSetting, + ); + }); + }); + + describe('validateSettingValue', () => { + it('should validate value types correctly', () => { + expect(() => + settingRepository['validateSettingValue']( + SettingType.text, + 'Valid Text', + ), + ).not.toThrow(); + + expect(() => + settingRepository['validateSettingValue'](SettingType.checkbox, true), + ).not.toThrow(); + + expect(() => + settingRepository['validateSettingValue'](SettingType.number, 123), + ).not.toThrow(); + + expect(() => + settingRepository['validateSettingValue'](SettingType.text, 123), + ).toThrow('Setting Model : Value must be a string!'); + }); + }); + + describe('validateSettingValue', () => { + const testCases = [ + { + type: SettingType.text, + value: 123, + error: 'Setting Model : Value must be a string!', + }, + { + type: SettingType.checkbox, + value: 'true', + error: 'Setting Model : Value must be a boolean!', + }, + { + type: SettingType.number, + value: '123', + }, + { + type: SettingType.multiple_text, + value: ['valid', 123], + }, + { + type: SettingType.attachment, + value: 123, + }, + { + type: SettingType.secret, + value: 123, + }, + { + type: SettingType.select, + value: 123, + }, + { + type: SettingType.multiple_attachment, + value: [123, 'valid'], + }, + ]; + + testCases.forEach(({ type, value }) => { + it(`should throw an error when value type does not match SettingType.${type}`, () => { + expect(() => + settingRepository['validateSettingValue'](type, value), + ).toThrow(); + }); + }); + }); +}); diff --git a/api/src/setting/repositories/setting.repository.ts b/api/src/setting/repositories/setting.repository.ts index e0523662..57117f49 100644 --- a/api/src/setting/repositories/setting.repository.ts +++ b/api/src/setting/repositories/setting.repository.ts @@ -7,18 +7,21 @@ */ import { Injectable } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; +import { + EventEmitter2, + IHookSettingsGroupLabelOperationMap, +} from '@nestjs/event-emitter'; import { InjectModel } from '@nestjs/mongoose'; import { Document, FilterQuery, Model, + Query, Types, UpdateQuery, UpdateWithAggregationPipeline, } from 'mongoose'; -import { I18nService } from '@/i18n/services/i18n.service'; import { BaseRepository } from '@/utils/generics/base-repository'; import { Setting } from '../schemas/setting.schema'; @@ -29,7 +32,6 @@ export class SettingRepository extends BaseRepository { constructor( readonly eventEmitter: EventEmitter2, @InjectModel(Setting.name) readonly model: Model, - private readonly i18n: I18nService, ) { super(eventEmitter, model, Setting); } @@ -55,6 +57,32 @@ export class SettingRepository extends BaseRepository { } } + /** + * Emits an event after a `Setting` has been updated. + * + * This method is used to synchronize global settings by emitting an event + * based on the `group` and `label` of the `Setting`. + * + * @param _query The Mongoose query object used to find and update the document. + * @param setting The updated `Setting` object. + */ + async postUpdate( + _query: Query< + Document, + Document, + unknown, + Setting, + 'findOneAndUpdate' + >, + setting: Setting, + ) { + const group = setting.group as keyof IHookSettingsGroupLabelOperationMap; + const label = setting.label as '*'; + + // Sync global settings var + this.eventEmitter.emit(`hook:${group}:${label}`, setting); + } + /** * Validates the `Setting` document after it has been retrieved. * @@ -65,7 +93,7 @@ export class SettingRepository extends BaseRepository { * * @param setting The `Setting` document to be validated. */ - private validateSettingValue(type: SettingType, value: any) { + public validateSettingValue(type: SettingType, value: any) { if ( (type === SettingType.text || type === SettingType.textarea) && typeof value !== 'string' &&