import { forwardRef, Module, Type } from '@nestjs/common'; import { DiscoveryModule } from '@nestjs/core'; import { Test } from '@nestjs/testing'; import { AllInOneModule } from '../all-in-one.module'; import { AppModule } from '../app.module'; import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module'; import { DomainEventRegistry } from '../domain-events/domain-event-registry'; import { WorkerModule } from '../worker/worker.module'; import { WorkerRuntimeModule } from '../worker/worker-runtime.module'; import { isHttpRole, rootModuleFor } from './root-module'; const importsOf = (module: object): unknown[] => (Reflect.getMetadata('imports', module) as unknown[] | undefined) ?? []; /** * The handler ids the DomainEventRegistry discovers under `root`. Compiled * only - no init, so nothing connects or starts a loop; the registry's * boot scan runs by hand on the constructed providers. */ async function discoveredHandlerIds(root: Type): Promise { const moduleRef = await Test.createTestingModule({ imports: [root], }).compile(); try { const registry = moduleRef.get(DomainEventRegistry); registry.onModuleInit(); return registry.handlerIds(); } finally { await moduleRef.close(); } } /** * Every module class reachable from `root` through imports - including * the imports of dynamic modules (also async ones like * ConfigModule.forRoot) and forwardRef()s. */ async function reachableModules(root: unknown): Promise> { const seen = new Set(); async function visit(entry: unknown): Promise { const resolved: unknown = await entry; // A circular file import can leave an undefined slot behind. if (typeof resolved !== 'object' && typeof resolved !== 'function') { return; } if (resolved === null) { return; } if ('forwardRef' in resolved) { return visit((resolved as { forwardRef: () => unknown }).forwardRef()); } if ('module' in resolved) { const dynamic = resolved as { module: unknown; imports?: unknown[] }; await visit(dynamic.module); for (const imported of dynamic.imports ?? []) { await visit(imported); } return; } if (seen.has(resolved)) { return; } seen.add(resolved); for (const imported of importsOf(resolved)) { await visit(imported); } } await visit(root); return seen; } // The API role never starts background loops: only the worker and "all" // (local development and tests) load the worker runtime. describe('rootModuleFor', () => { it('the api module never imports the worker runtime, not even transitively', async () => { const reachable = await reachableModules(AppModule); expect(reachable).not.toContain(WorkerRuntimeModule); // The walk really goes deep: into feature modules and dynamic modules. expect(reachable).toContain(DiscoveryModule); expect(reachable).toContain(ThrottlerStorageModule); }); it('api starts the API module alone', () => { expect(rootModuleFor('api')).toBe(AppModule); }); it('all adds the worker runtime to the API', () => { expect(rootModuleFor('all')).toBe(AllInOneModule); expect(importsOf(AllInOneModule)).toEqual([AppModule, WorkerRuntimeModule]); }); it('the worker runs the runtime without the HTTP API', async () => { const reachable = await reachableModules(WorkerModule); expect(reachable).toContain(WorkerRuntimeModule); expect(reachable).not.toContain(AppModule); }); // Only the worker dispatches events. A handler in a module the worker // does not import would be registered by the API and never run. it('the worker discovers exactly the domain event handlers of the API', async () => { // Compiling the API constructs JwtStrategy, which needs a secret. CI // builds have no .env file, so the test supplies one itself instead of // relying on another suite of the same worker having set it. const previousSecret = process.env.JWT_ACCESS_SECRET; process.env.JWT_ACCESS_SECRET ||= 'root-module-spec-'.padEnd(40, 'x'); try { const api = await discoveredHandlerIds(AppModule); const worker = await discoveredHandlerIds(WorkerModule); expect(api).toContain('webhooks.forward'); expect(worker).toEqual(api); } finally { if (previousSecret === undefined) { delete process.env.JWT_ACCESS_SECRET; } else { process.env.JWT_ACCESS_SECRET = previousSecret; } } }, 30_000); }); describe('reachableModules', () => { @Module({ imports: [WorkerRuntimeModule] }) class InnerModule {} @Module({ imports: [forwardRef(() => InnerModule)] }) class ViaForwardRefModule {} @Module({ imports: [Promise.resolve({ module: class {}, imports: [InnerModule] })], }) class ViaAsyncDynamicModule {} it.each([ ['a plain import chain', InnerModule], ['a forwardRef', ViaForwardRefModule], ['the imports of an async dynamic module', ViaAsyncDynamicModule], ])('finds the worker runtime behind %s', async (_case, root) => { expect(await reachableModules(root)).toContain(WorkerRuntimeModule); }); }); describe('isHttpRole', () => { it('admits exactly the roles dist/main.js starts', () => { expect(isHttpRole('api')).toBe(true); expect(isHttpRole('all')).toBe(true); expect(isHttpRole('worker')).toBe(false); expect(isHttpRole('job')).toBe(false); }); });