hexabot/api/src/utils/test/utils.ts
2025-06-18 14:08:48 +01:00

227 lines
6.5 KiB
TypeScript

/*
* 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 { CACHE_MANAGER } from '@nestjs/cache-manager';
import { ModuleMetadata, Provider } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ModelDefinition, MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { LoggerService } from '@/logger/logger.service';
import { LifecycleHookManager } from '../generics/lifecycle-hook-manager';
type TTypeOrToken = [
new (...args: any[]) => any,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
...(new (...args: any[]) => any[]),
];
type TModel = ModelDefinition | `${string}Model`;
type ToUnionArray<T> = (NonNullable<T> extends (infer U)[] ? U : never)[];
type buildTestingMocksProps<
P extends ModuleMetadata['providers'] = ModuleMetadata['providers'],
C extends ModuleMetadata['controllers'] = ModuleMetadata['controllers'],
> = ModuleMetadata & {
models?: TModel[];
} & (
| {
providers: NonNullable<P>;
controllers: NonNullable<C>;
autoInjectFrom: ('providers' | 'controllers')[];
}
| {
providers: NonNullable<P>;
autoInjectFrom?: 'providers'[];
}
| {
controllers: NonNullable<C>;
autoInjectFrom?: 'controllers'[];
}
| {
providers?: never;
controllers?: never;
autoInjectFrom?: never;
}
);
const findInstances = async <T extends TTypeOrToken>(
type: keyof TestingModule,
module: TestingModule,
typesOrTokens: T,
): Promise<{ [K in keyof T]: InstanceType<T[K]> }> =>
Promise.all(
typesOrTokens.map((typeOrToken) =>
module[type.toString()]<InstanceType<typeof typeOrToken>>(typeOrToken),
),
);
const extractInstances =
(type: keyof TestingModule, module: TestingModule) =>
async <T extends TTypeOrToken>(types: T) =>
await findInstances(type, module, types);
const getParamTypes = (provider: Provider) =>
Reflect.getMetadata('design:paramtypes', provider) || [];
const getClassDependencies = (parentClass: Provider): Provider[] => {
const dependencies: Provider[] = [];
const seenClasses = new Set<Provider>();
const classQueue: Provider[] = [parentClass];
while (classQueue.length > 0) {
const currentClass = classQueue.pop()!;
if (seenClasses.has(currentClass)) {
continue;
}
seenClasses.add(currentClass);
if (currentClass) {
getParamTypes(currentClass).forEach((paramType: Provider) => {
if (paramType && !seenClasses.has(paramType)) {
classQueue.push(paramType);
dependencies.push(paramType);
}
});
}
}
return dependencies;
};
const getModel = (name: string, suffix = ''): ModelDefinition => {
const modelName = name.replace(suffix, '');
const model = LifecycleHookManager.getModel(modelName);
if (!model) {
throw new Error(`Unable to find model for name '${modelName}!'`);
}
return model;
};
const getNestedModels = (
dynamicProviders: Provider[],
suffix = '',
): ModelDefinition[] =>
dynamicProviders.reduce((acc, dynamicProvider) => {
if ('name' in dynamicProvider && dynamicProvider.name.endsWith(suffix)) {
const model = getModel(dynamicProvider.name, suffix);
acc.push(model);
}
return acc;
}, [] as ModelDefinition[]);
const filterNestedDependencies = (dependency: Provider) =>
dependency.valueOf().toString().slice(0, 6) === 'class ';
const getNestedDependencies = (dynamicProviders: Provider[]): Provider[] => {
const nestedDependencies = new Set<Provider>();
dynamicProviders.filter(filterNestedDependencies).forEach((provider) => {
getClassDependencies(provider)
.filter(filterNestedDependencies)
.forEach((dependency) => {
if (
!dynamicProviders.includes(dependency) &&
!dynamicProviders.find(
(dynamicProvider) =>
'provide' in dynamicProvider &&
dynamicProvider.provide === dependency,
)
) {
nestedDependencies.add(dependency);
}
});
});
return [...nestedDependencies];
};
const canInjectModels = (imports: buildTestingMocksProps['imports']): boolean =>
(imports || []).some(
(dynamicModule) =>
'module' in dynamicModule &&
dynamicModule.module.name === 'MongooseModule',
);
const getModels = (models: TModel[]): ModelDefinition[] =>
models.map((model) =>
typeof model === 'string' ? getModel(model, 'Model') : model,
);
export const buildTestingMocks = async ({
models = [],
imports = [],
providers = [],
controllers = [],
autoInjectFrom,
...rest
}: buildTestingMocksProps) => {
const nestedProviders = new Set<Provider>();
const injectionFrom = autoInjectFrom as ToUnionArray<typeof autoInjectFrom>;
const canAutoInjectFromProviders = injectionFrom?.includes('providers');
const canAutoInjectFromControllers = injectionFrom?.includes('controllers');
if (canAutoInjectFromProviders) {
[...providers, ...getNestedDependencies(providers)].forEach((provider) =>
nestedProviders.add(provider),
);
}
if (canAutoInjectFromControllers) {
[...getNestedDependencies(controllers)].forEach((controller) =>
nestedProviders.add(controller),
);
}
const module = await Test.createTestingModule({
imports: [
...(canInjectModels(imports)
? [
MongooseModule.forFeature([
...getModels(models),
...(autoInjectFrom
? getNestedModels([...nestedProviders], 'Repository')
: []),
]),
]
: []),
...imports,
],
providers: [
LoggerService,
EventEmitter2,
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
set: jest.fn(),
get: jest.fn(),
},
},
...(autoInjectFrom ? [...nestedProviders] : []),
...providers,
],
controllers,
...rest,
}).compile();
return {
module,
getMocks: extractInstances('get', module),
resolveMocks: extractInstances('resolve', module),
};
};