first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
import type { ModuleInterface, ModuleMetadata } from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import type { LicenseState } from '../../license-state';
|
||||
import { ModuleConfusionError } from '../errors/module-confusion.error';
|
||||
import { ModuleRegistry } from '../module-registry';
|
||||
import { MODULE_NAMES } from '../modules.config';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
process.env = {};
|
||||
Container.reset();
|
||||
});
|
||||
|
||||
describe('eligibleModules', () => {
|
||||
it('should consider all default modules eligible', () => {
|
||||
expect(Container.get(ModuleRegistry).eligibleModules).toEqual(MODULE_NAMES);
|
||||
});
|
||||
|
||||
it('should consider a module ineligible if it was disabled via env var', () => {
|
||||
process.env.N8N_DISABLED_MODULES = 'insights';
|
||||
expect(Container.get(ModuleRegistry).eligibleModules).toEqual([
|
||||
'external-secrets',
|
||||
'community-packages',
|
||||
'data-table',
|
||||
'mcp',
|
||||
'provisioning',
|
||||
'breaking-changes',
|
||||
'source-control',
|
||||
'dynamic-credentials',
|
||||
'chat-hub',
|
||||
'sso-oidc',
|
||||
'sso-saml',
|
||||
'log-streaming',
|
||||
'ldap',
|
||||
'quick-connect',
|
||||
'workflow-builder',
|
||||
'redaction',
|
||||
'instance-registry',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should consider a module eligible if it was enabled via env var', () => {
|
||||
process.env.N8N_ENABLED_MODULES = 'data-table';
|
||||
expect(Container.get(ModuleRegistry).eligibleModules).toEqual([
|
||||
'insights',
|
||||
'external-secrets',
|
||||
'community-packages',
|
||||
'data-table',
|
||||
'mcp',
|
||||
'provisioning',
|
||||
'breaking-changes',
|
||||
'source-control',
|
||||
'dynamic-credentials',
|
||||
'chat-hub',
|
||||
'sso-oidc',
|
||||
'sso-saml',
|
||||
'log-streaming',
|
||||
'ldap',
|
||||
'quick-connect',
|
||||
'workflow-builder',
|
||||
'redaction',
|
||||
'instance-registry',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw `ModuleConfusionError` if a module is both enabled and disabled', () => {
|
||||
process.env.N8N_ENABLED_MODULES = 'insights';
|
||||
process.env.N8N_DISABLED_MODULES = 'insights';
|
||||
expect(() => Container.get(ModuleRegistry).eligibleModules).toThrow(ModuleConfusionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadModules', () => {
|
||||
it('should load entities defined by modules', async () => {
|
||||
const FirstEntity = class FirstEntityClass {};
|
||||
const SecondEntity = class SecondEntityClass {};
|
||||
|
||||
const ModuleClass = {
|
||||
entities: jest.fn().mockReturnValue([FirstEntity, SecondEntity]),
|
||||
};
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
getClasses: jest.fn().mockReturnValue([ModuleClass]),
|
||||
});
|
||||
|
||||
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
|
||||
|
||||
await moduleRegistry.loadModules([]);
|
||||
|
||||
expect(moduleRegistry.entities).toEqual([FirstEntity, SecondEntity]);
|
||||
});
|
||||
|
||||
it('should load no entities if none are defined by modules', async () => {
|
||||
const ModuleClass = { entities: jest.fn().mockReturnValue([]) };
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
getClasses: jest.fn().mockReturnValue([ModuleClass]),
|
||||
});
|
||||
|
||||
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
|
||||
|
||||
await moduleRegistry.loadModules([]);
|
||||
|
||||
expect(moduleRegistry.entities).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initModules', () => {
|
||||
it('should init module if it has no feature flag', async () => {
|
||||
const ModuleClass = { init: jest.fn() };
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
getEntries: jest
|
||||
.fn()
|
||||
.mockReturnValue([['test-module', { licenseFlag: undefined, class: ModuleClass }]]),
|
||||
});
|
||||
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||
|
||||
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
|
||||
|
||||
await moduleRegistry.initModules('main');
|
||||
|
||||
expect(ModuleClass.init).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should init module if it is licensed', async () => {
|
||||
const ModuleClass = { init: jest.fn() };
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
getEntries: jest
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
['test-module', { licenseFlag: 'feat:testFeature', class: ModuleClass }],
|
||||
]),
|
||||
});
|
||||
const licenseState = mock<LicenseState>({ isLicensed: jest.fn().mockReturnValue(true) });
|
||||
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||
|
||||
const moduleRegistry = new ModuleRegistry(moduleMetadata, licenseState, mock(), mock());
|
||||
|
||||
await moduleRegistry.initModules('main');
|
||||
|
||||
expect(ModuleClass.init).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip init for unlicensed module', async () => {
|
||||
const ModuleClass = { init: jest.fn() };
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
getEntries: jest
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
['test-module', { licenseFlag: 'feat:testFeature', class: ModuleClass }],
|
||||
]),
|
||||
});
|
||||
const licenseState = mock<LicenseState>({ isLicensed: jest.fn().mockReturnValue(false) });
|
||||
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||
|
||||
const moduleRegistry = new ModuleRegistry(moduleMetadata, licenseState, mock(), mock());
|
||||
|
||||
await moduleRegistry.initModules('main');
|
||||
|
||||
expect(ModuleClass.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept module without `init` method', async () => {
|
||||
const ModuleClass = {};
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
getEntries: jest
|
||||
.fn()
|
||||
.mockReturnValue([['test-module', { licenseFlag: undefined, class: ModuleClass }]]),
|
||||
});
|
||||
|
||||
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||
|
||||
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
|
||||
|
||||
await moduleRegistry.initModules('main');
|
||||
|
||||
await expect(moduleRegistry.initModules('main')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('registers settings', async () => {
|
||||
// ARRANGE
|
||||
const moduleName = 'test-module';
|
||||
const moduleSettings = { foo: 1 };
|
||||
const ModuleClass: ModuleInterface = {
|
||||
init: jest.fn(),
|
||||
settings: jest.fn().mockReturnValue(moduleSettings),
|
||||
};
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
getEntries: jest.fn().mockReturnValue([[moduleName, { class: ModuleClass }]]),
|
||||
});
|
||||
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||
|
||||
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
|
||||
|
||||
// ACT
|
||||
await moduleRegistry.initModules('main');
|
||||
|
||||
// ASSERT
|
||||
expect(ModuleClass.settings).toHaveBeenCalled();
|
||||
expect(moduleRegistry.settings.has(moduleName)).toBe(true);
|
||||
expect(moduleRegistry.settings.get(moduleName)).toBe(moduleSettings);
|
||||
});
|
||||
|
||||
it('activates module with settings', async () => {
|
||||
// ARRANGE
|
||||
const moduleName = 'test-module';
|
||||
const moduleSettings = { foo: 1 };
|
||||
const ModuleClass: ModuleInterface = {
|
||||
init: jest.fn(),
|
||||
settings: jest.fn().mockReturnValue(moduleSettings),
|
||||
};
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
getEntries: jest.fn().mockReturnValue([[moduleName, { class: ModuleClass }]]),
|
||||
});
|
||||
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||
|
||||
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
|
||||
|
||||
// ACT
|
||||
await moduleRegistry.initModules('main');
|
||||
|
||||
// ASSERT
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(moduleRegistry.isActive(moduleName as any)).toBe(true);
|
||||
expect(moduleRegistry.getActiveModules()).toEqual([moduleName]);
|
||||
});
|
||||
|
||||
it('activates module without settings', async () => {
|
||||
// ARRANGE
|
||||
const moduleName = 'test-module';
|
||||
const ModuleClass: ModuleInterface = {
|
||||
init: jest.fn(),
|
||||
};
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
getEntries: jest.fn().mockReturnValue([[moduleName, { class: ModuleClass }]]),
|
||||
});
|
||||
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||
|
||||
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
|
||||
|
||||
// ACT
|
||||
await moduleRegistry.initModules('main');
|
||||
|
||||
// ASSERT
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(moduleRegistry.isActive(moduleName as any)).toBe(true);
|
||||
expect(moduleRegistry.getActiveModules()).toEqual([moduleName]);
|
||||
});
|
||||
|
||||
it('registers context for module with `context` method', async () => {
|
||||
// ARRANGE
|
||||
const moduleName = 'test-module';
|
||||
const moduleContext = { proxy: 'test-proxy', config: { enabled: true } };
|
||||
const ModuleClass: ModuleInterface = {
|
||||
init: jest.fn(),
|
||||
context: jest.fn().mockReturnValue(moduleContext),
|
||||
};
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
getEntries: jest.fn().mockReturnValue([[moduleName, { class: ModuleClass }]]),
|
||||
});
|
||||
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||
|
||||
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
|
||||
|
||||
// ACT
|
||||
await moduleRegistry.initModules('main');
|
||||
|
||||
// ASSERT
|
||||
expect(ModuleClass.context).toHaveBeenCalled();
|
||||
expect(moduleRegistry.context.has(moduleName)).toBe(true);
|
||||
expect(moduleRegistry.context.get(moduleName)).toBe(moduleContext);
|
||||
});
|
||||
|
||||
it('does not register context for module without `context` method', async () => {
|
||||
// ARRANGE
|
||||
const moduleName = 'test-module';
|
||||
const ModuleClass: ModuleInterface = { init: jest.fn() };
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
getEntries: jest.fn().mockReturnValue([[moduleName, { class: ModuleClass }]]),
|
||||
});
|
||||
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||
|
||||
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
|
||||
|
||||
// ACT
|
||||
await moduleRegistry.initModules('main');
|
||||
|
||||
// ASSERT
|
||||
expect(moduleRegistry.context.has(moduleName)).toBe(false);
|
||||
});
|
||||
|
||||
it('should init module with matching instance type', async () => {
|
||||
const ModuleClass = { init: jest.fn() };
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
getEntries: jest
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
['test-module', { instanceTypes: ['main', 'worker'], class: ModuleClass }],
|
||||
]),
|
||||
});
|
||||
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||
|
||||
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
|
||||
|
||||
await moduleRegistry.initModules('main');
|
||||
|
||||
expect(ModuleClass.init).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip init for module with non-matching instance type', async () => {
|
||||
const ModuleClass = { init: jest.fn() };
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
getEntries: jest
|
||||
.fn()
|
||||
.mockReturnValue([['test-module', { instanceTypes: ['worker'], class: ModuleClass }]]),
|
||||
});
|
||||
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||
|
||||
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
|
||||
|
||||
await moduleRegistry.initModules('main');
|
||||
|
||||
expect(ModuleClass.init).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadDir', () => {
|
||||
it('should load dirs defined by modules', async () => {
|
||||
const TEST_LOAD_DIR = '/path/to/module/load/dir';
|
||||
const ModuleClass = {
|
||||
entities: jest.fn().mockReturnValue([]),
|
||||
loadDir: jest.fn().mockReturnValue(TEST_LOAD_DIR),
|
||||
};
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
getClasses: jest.fn().mockReturnValue([ModuleClass]),
|
||||
});
|
||||
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
|
||||
|
||||
await moduleRegistry.loadModules([]); // empty to skip dynamic imports
|
||||
|
||||
expect(moduleRegistry.loadDirs).toEqual([TEST_LOAD_DIR]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { UnknownModuleError } from '../errors/unknown-module.error';
|
||||
import { ModulesConfig } from '../modules.config';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
process.env = {};
|
||||
Container.reset();
|
||||
});
|
||||
|
||||
it('should throw `UnknownModuleError` if any enabled module name is invalid', () => {
|
||||
process.env.N8N_ENABLED_MODULES = 'insights,invalidModule';
|
||||
expect(() => Container.get(ModulesConfig)).toThrowError(UnknownModuleError);
|
||||
});
|
||||
|
||||
it('should throw `UnknownModuleError` if any disabled module name is invalid', () => {
|
||||
process.env.N8N_DISABLED_MODULES = 'insights,invalidModule';
|
||||
expect(() => Container.get(ModulesConfig)).toThrowError(UnknownModuleError);
|
||||
});
|
||||
Reference in New Issue
Block a user