import { DyFM_EnvironmentFlag } from '@futdevpro/fsm-dynamo'; import { DyNTS_global_settings } from '../../../_collections/global-settings.const'; import { DyNTS_GlobalService } from '../../../_services/core/global.service'; import { DyNTS_ScopedConfig_Level } from '../_enums/dynts-scoped-config-level.enum'; import { DyNTS_ScopedConfig } from '../_models/data-models/dynts-scoped-config.data-model'; import { DyNTS_ScopedConfigResolvedValue } from '../_models/interfaces/dynts-scoped-config.interface'; import { DyNTS_ScopedConfig_DataService } from './dynts-scoped-config.data-service'; import { DyNTS_ScopedConfig_ControlService } from './dynts-scoped-config.control-service'; /** * BFR-AM-004 — a generikus scoped-config precedencia-engine spec-jei. * * A `DyNTS_ScopedConfig_ControlService` minden DB-művelet előtt lazy `new DyNTS_ScopedConfig_DataService`-t * készít (eager DB-resolve a base-ctor-ban — memory: dynts_dataservice_eager_resolve), ezért a teszt: * (1) stub-olja a `DyNTS_GlobalService.getDBService`-t (hogy a base-ctor ne dobjon), * (2) stub-olja a `DyNTS_ScopedConfig_DataService.prototype.findActiveList`-et a kontrollált * rekord-halmazzal (a precedencia memóriában fut, így NEM kell élő Mongo) → a DB-round-trip * spec-ek így tisztán futnak Mongo NÉLKÜL (skip-guard nem szükséges). */ describe('| DyNTS_ScopedConfig_ControlService', () => { let control: DyNTS_ScopedConfig_ControlService; let mockDBService: jasmine.SpyObj<{ find: () => Promise; findOne: () => Promise }>; /** Egy aktív rekord gyors gyártása a kontrollált rekord-halmazhoz. */ const makeRecord = (set: Partial): DyNTS_ScopedConfig => new DyNTS_ScopedConfig(set); beforeAll(() => { if (!DyNTS_global_settings.systemShortCodeName) { (DyNTS_global_settings as { systemShortCodeName?: string }).systemShortCodeName = 'TEST'; } if (!DyNTS_global_settings.env_settings) { (DyNTS_global_settings as { env_settings?: unknown }).env_settings = { environment: DyFM_EnvironmentFlag.local, }; } }); beforeEach(() => { // A base-ctor eager getDBService-jét stub-oljuk, hogy a lazy DataService-példányok ne dobjanak. mockDBService = jasmine.createSpyObj('DyNTS_DBService', [ 'find', 'findOne' ]); mockDBService.find.and.returnValue(Promise.resolve([])); mockDBService.findOne.and.returnValue(Promise.resolve(null)); spyOn(DyNTS_GlobalService, 'getDBService').and.returnValue(mockDBService as never); // Friss singleton-állapot minden teszthez (új instance + tiszta cache). (DyNTS_ScopedConfig_ControlService as unknown as { _instance?: DyNTS_ScopedConfig_ControlService })._instance = undefined; control = DyNTS_ScopedConfig_ControlService.getInstance(); control.invalidateCache(); }); /** A kontrollált aktív rekord-halmaz beállítása (a `loadEffectiveRecords` ezt kapja vissza). */ const stubRecords = (records: DyNTS_ScopedConfig[]): void => { spyOn(DyNTS_ScopedConfig_DataService.prototype, 'findActiveList').and.returnValue(Promise.resolve(records)); }; describe('| getInstance', () => { it('| should return the same singleton instance', () => { expect(DyNTS_ScopedConfig_ControlService.getInstance()).toBe(control); }); }); describe('| resolve — precedence', () => { it('| should return builtinDefault when no DB record exists', async () => { stubRecords([]); const resolved: DyNTS_ScopedConfigResolvedValue = await control.resolve('read.topK', { builtinDefault: 5 }); expect(resolved.value).toBe(5); expect(resolved.resolvedFrom).toBe('builtin'); }); it('| should return global over builtinDefault', async () => { stubRecords([ makeRecord({ level: DyNTS_ScopedConfig_Level.global, key: 'read.topK', value: 10, setBy: 'manual' }), ]); const resolved: DyNTS_ScopedConfigResolvedValue = await control.resolve('read.topK', { builtinDefault: 5 }); expect(resolved.value).toBe(10); expect(resolved.resolvedFrom).toBe('global'); expect(resolved.setBy).toBe('manual'); }); it('| should return table over global', async () => { stubRecords([ makeRecord({ level: DyNTS_ScopedConfig_Level.global, key: 'read.topK', value: 10 }), makeRecord({ level: DyNTS_ScopedConfig_Level.table, tableScope: 'documents', key: 'read.topK', value: 20 }), ]); const resolved: DyNTS_ScopedConfigResolvedValue = await control.resolve('read.topK', { table: 'documents', builtinDefault: 5 }); expect(resolved.value).toBe(20); expect(resolved.resolvedFrom).toBe('table'); }); it('| should return scope-root over table', async () => { stubRecords([ makeRecord({ level: DyNTS_ScopedConfig_Level.global, key: 'read.topK', value: 10 }), makeRecord({ level: DyNTS_ScopedConfig_Level.table, tableScope: 'documents', key: 'read.topK', value: 20 }), makeRecord({ level: DyNTS_ScopedConfig_Level.scope, tableScope: 'documents', scopeId: 'root', key: 'read.topK', value: 30 }), ]); const resolved: DyNTS_ScopedConfigResolvedValue = await control.resolve('read.topK', { table: 'documents', scopePath: [{ scopeId: 'root' }], builtinDefault: 5, }); expect(resolved.value).toBe(30); expect(resolved.resolvedFrom).toBe('scope'); expect(resolved.scopeId).toBe('root'); }); it('| should return scope-LEAF over scope-root (deepest wins)', async () => { stubRecords([ makeRecord({ level: DyNTS_ScopedConfig_Level.table, tableScope: 'documents', key: 'read.topK', value: 20 }), makeRecord({ level: DyNTS_ScopedConfig_Level.scope, tableScope: 'documents', scopeId: 'root', key: 'read.topK', value: 30 }), makeRecord({ level: DyNTS_ScopedConfig_Level.scope, tableScope: 'documents', scopeId: 'leaf', key: 'read.topK', value: 40 }), ]); // scopePath: gyökér→levél; a feloldás a LEVÉBŐL (leaf) indul → 'leaf' nyer 'root' felett. const resolved: DyNTS_ScopedConfigResolvedValue = await control.resolve('read.topK', { table: 'documents', scopePath: [{ scopeId: 'root' }, { scopeId: 'leaf' }], builtinDefault: 5, }); expect(resolved.value).toBe(40); expect(resolved.resolvedFrom).toBe('scope'); expect(resolved.scopeId).toBe('leaf'); }); it('| should fall back to scope-root when leaf has no record for the key', async () => { stubRecords([ makeRecord({ level: DyNTS_ScopedConfig_Level.scope, tableScope: 'documents', scopeId: 'root', key: 'read.topK', value: 30 }), // 'leaf' csak EGY MÁSIK kulcsot állít — read.topK-ra nincs leaf-rekord → scope-root nyer. makeRecord({ level: DyNTS_ScopedConfig_Level.scope, tableScope: 'documents', scopeId: 'leaf', key: 'read.maxResults', value: 99 }), ]); const resolved: DyNTS_ScopedConfigResolvedValue = await control.resolve('read.topK', { table: 'documents', scopePath: [{ scopeId: 'root' }, { scopeId: 'leaf' }], builtinDefault: 5, }); expect(resolved.value).toBe(30); expect(resolved.scopeId).toBe('root'); }); it('| should NOT use a table record from a different tableScope', async () => { stubRecords([ makeRecord({ level: DyNTS_ScopedConfig_Level.table, tableScope: 'other', key: 'read.topK', value: 999 }), makeRecord({ level: DyNTS_ScopedConfig_Level.global, key: 'read.topK', value: 10 }), ]); const resolved: DyNTS_ScopedConfigResolvedValue = await control.resolve('read.topK', { table: 'documents', builtinDefault: 5 }); // 'other' table-rekord nem releváns → global nyer. expect(resolved.value).toBe(10); expect(resolved.resolvedFrom).toBe('global'); }); it('| should ignore scope/table records when context has no table', async () => { stubRecords([ makeRecord({ level: DyNTS_ScopedConfig_Level.table, tableScope: 'documents', key: 'read.topK', value: 20 }), makeRecord({ level: DyNTS_ScopedConfig_Level.global, key: 'read.topK', value: 10 }), ]); const resolved: DyNTS_ScopedConfigResolvedValue = await control.resolve('read.topK', { builtinDefault: 5 }); expect(resolved.value).toBe(10); expect(resolved.resolvedFrom).toBe('global'); }); }); describe('| resolve — Mixed value get', () => { it('| should resolve a string[] (Mixed) value intact', async () => { const patterns: string[] = [ '**/node_modules/**', '**/.git/**' ]; stubRecords([ makeRecord({ level: DyNTS_ScopedConfig_Level.global, key: 'scan.ignorePatterns', value: patterns }), ]); const resolved: DyNTS_ScopedConfigResolvedValue = await control.resolve('scan.ignorePatterns', { builtinDefault: [] }); expect(resolved.value).toEqual(patterns); expect(resolved.resolvedFrom).toBe('global'); }); it('| should resolve an object (Mixed) value intact', async () => { const obj = { provider: 'openai', model: 'text-embedding-3-large' }; stubRecords([ makeRecord({ level: DyNTS_ScopedConfig_Level.global, key: 'embedding.config', value: obj }), ]); const resolved: DyNTS_ScopedConfigResolvedValue<{ provider: string; model: string }> = await control.resolve('embedding.config', { builtinDefault: { provider: '', model: '' } }); expect(resolved.value).toEqual(obj); }); }); describe('| resolveAll', () => { it('| should merge all configured keys with their resolved source level', async () => { stubRecords([ makeRecord({ level: DyNTS_ScopedConfig_Level.global, key: 'read.topK', value: 10 }), makeRecord({ level: DyNTS_ScopedConfig_Level.table, tableScope: 'documents', key: 'read.topK', value: 20 }), makeRecord({ level: DyNTS_ScopedConfig_Level.global, key: 'read.minScore', value: 0.5 }), makeRecord({ level: DyNTS_ScopedConfig_Level.scope, tableScope: 'documents', scopeId: 'leaf', key: 'read.maxResults', value: 99 }), ]); const all = await control.resolveAll({ table: 'documents', scopePath: [{ scopeId: 'leaf' }] }); // read.topK: table nyer a global felett. expect(all['read.topK'].value).toBe(20); expect(all['read.topK'].resolvedFrom).toBe('table'); // read.minScore: csak global. expect(all['read.minScore'].value).toBe(0.5); expect(all['read.minScore'].resolvedFrom).toBe('global'); // read.maxResults: scope (leaf). expect(all['read.maxResults'].value).toBe(99); expect(all['read.maxResults'].resolvedFrom).toBe('scope'); }); it('| should return empty object when no records configured', async () => { stubRecords([]); const all = await control.resolveAll({ table: 'documents' }); expect(Object.keys(all).length).toBe(0); }); }); describe('| set', () => { it('| should archive an existing active record then write the new one', async () => { const existing: DyNTS_ScopedConfig = makeRecord({ _id: 'old-id', level: DyNTS_ScopedConfig_Level.global, key: 'read.topK', value: 10 }); const savedNew: DyNTS_ScopedConfig = makeRecord({ _id: 'new-id', level: DyNTS_ScopedConfig_Level.global, key: 'read.topK', value: 25 }); const findActiveSpy = spyOn(DyNTS_ScopedConfig_DataService.prototype, 'findActive') .and.returnValue(Promise.resolve(existing)); const deleteSpy = spyOn(DyNTS_ScopedConfig_DataService.prototype, 'deleteData') .and.returnValue(Promise.resolve()); const saveSpy = spyOn(DyNTS_ScopedConfig_DataService.prototype, 'saveData') .and.returnValue(Promise.resolve(savedNew)); const result: DyNTS_ScopedConfig = await control.set(DyNTS_ScopedConfig_Level.global, 'read.topK', 25, { setBy: 'cli' }); expect(findActiveSpy).toHaveBeenCalled(); expect(deleteSpy).toHaveBeenCalledWith('old-id'); expect(saveSpy).toHaveBeenCalled(); // A kiírt rekord a helyes kulcsot + értéket + audit-ot hordozza. const written: DyNTS_ScopedConfig = saveSpy.calls.mostRecent().args[0]; expect(written.key).toBe('read.topK'); expect(written.value).toBe(25); expect(written.setBy).toBe('cli'); expect(result).toBe(savedNew); }); it('| should write a new record without deleting when no active record exists', async () => { spyOn(DyNTS_ScopedConfig_DataService.prototype, 'findActive').and.returnValue(Promise.resolve(null)); const deleteSpy = spyOn(DyNTS_ScopedConfig_DataService.prototype, 'deleteData').and.returnValue(Promise.resolve()); spyOn(DyNTS_ScopedConfig_DataService.prototype, 'saveData') .and.callFake((data?: DyNTS_ScopedConfig) => Promise.resolve(data)); const result: DyNTS_ScopedConfig = await control.set( DyNTS_ScopedConfig_Level.table, 'read.topK', 20, { tableScope: 'documents' }, ); expect(deleteSpy).not.toHaveBeenCalled(); expect(result.value).toBe(20); expect(result.tableScope).toBe('documents'); expect(result.setBy).toBe('system'); }); it('| should throw when table level set is missing tableScope', async () => { await expectAsync( control.set(DyNTS_ScopedConfig_Level.table, 'read.topK', 20, {}), ).toBeRejectedWithError(/tableScope/); }); it('| should throw when scope level set is missing scopeId', async () => { await expectAsync( control.set(DyNTS_ScopedConfig_Level.scope, 'read.topK', 20, { tableScope: 'documents' }), ).toBeRejectedWithError(/scopeId/); }); }); });