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,87 @@
|
||||
# Migration Testing Helpers
|
||||
|
||||
This package provides utilities for testing database migrations by allowing you to stop before a specific migration, insert test data, and then run that migration.
|
||||
|
||||
## API
|
||||
|
||||
### `initDbUpToMigration(beforeMigrationName: string): Promise<void>`
|
||||
|
||||
Initializes the database and runs all migrations up to (but not including) the specified migration.
|
||||
|
||||
**Parameters:**
|
||||
- `beforeMigrationName`: The class name of the migration to stop before (e.g., `'AddUserRole1234567890'`)
|
||||
|
||||
**Throws:**
|
||||
- `UnexpectedError` if the migration is not found or database is not initialized
|
||||
|
||||
### `runSingleMigration(migrationName: string): Promise<void>`
|
||||
|
||||
Runs a single migration by name.
|
||||
|
||||
**Parameters:**
|
||||
- `migrationName`: The class name of the migration to run (e.g., `'AddUserRole1234567890'`)
|
||||
|
||||
**Throws:**
|
||||
- `UnexpectedError` if the migration is not found or database is not initialized
|
||||
|
||||
## `undoLastSingleMigration(): Promise<void>`
|
||||
|
||||
Undoes the last single migration.
|
||||
|
||||
|
||||
## Usage Example
|
||||
|
||||
```typescript
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource } from '@n8n/typeorm';
|
||||
import { initDbUpToMigration, runSingleMigration } from '@n8n/backend-test-utils';
|
||||
|
||||
describe('AddUserRole1234567890 Migration', () => {
|
||||
let dataSource: DataSource;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Initialize database but stop BEFORE the migration we want to test
|
||||
await initDbUpToMigration('AddUserRole1234567890');
|
||||
dataSource = Container.get(DataSource);
|
||||
});
|
||||
|
||||
it('should add role column to users table', async () => {
|
||||
// Insert test data in the OLD schema (before migration)
|
||||
// You should not use Repositories, because these will break after schema changes
|
||||
// over time.
|
||||
await dataSource.query(`
|
||||
INSERT INTO users (id, email, password)
|
||||
VALUES (1, 'test@example.com', 'hashed_password')
|
||||
`);
|
||||
|
||||
// Run the migration
|
||||
await runSingleMigration('AddUserRole1234567890');
|
||||
|
||||
// Verify the migration worked correctly
|
||||
const users = await dataSource.query('SELECT * FROM users WHERE id = 1');
|
||||
expect(users[0].role).toBe('member'); // Default role was added
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **`initDbUpToMigration`**:
|
||||
- Gets all available migrations from TypeORM DataSource
|
||||
- Finds the target migration by name
|
||||
- Temporarily replaces the migrations array with only migrations before the target
|
||||
- Wraps and runs those migrations
|
||||
- Restores the full migrations array
|
||||
|
||||
2. **`runSingleMigration`**:
|
||||
- Finds the specific migration by name
|
||||
- Temporarily replaces the migrations array with only that migration
|
||||
- Wraps and runs that single migration
|
||||
- Restores the full migrations array
|
||||
|
||||
## Important Notes
|
||||
|
||||
- These functions must be used with an initialized database connection (after `dbConnection.init()`)
|
||||
- Do NOT call `dbConnection.migrate()` before using these helpers - they replace that step
|
||||
- Migration wrapping is idempotent - migrations won't be double-wrapped
|
||||
- The full migrations array is always restored after operations complete (even on error)
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import { baseConfig } from '@n8n/eslint-config/base';
|
||||
|
||||
export default defineConfig(baseConfig, {
|
||||
rules: {
|
||||
// TODO: Remove this
|
||||
'@typescript-eslint/require-await': 'warn',
|
||||
'@typescript-eslint/naming-convention': 'warn',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@n8n/backend-test-utils",
|
||||
"version": "1.11.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome ci .",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"watch": "tsc -p tsconfig.build.json --watch",
|
||||
"test": "echo \"WARNING: no test specified\" && exit 0",
|
||||
"test:dev": "echo \"WARNING: no test specified\" && exit 0"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "src/index.ts",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@n8n/backend-common": "workspace:*",
|
||||
"@n8n/config": "workspace:*",
|
||||
"@n8n/constants": "workspace:*",
|
||||
"@n8n/db": "workspace:*",
|
||||
"@n8n/di": "workspace:*",
|
||||
"@n8n/permissions": "workspace:*",
|
||||
"@n8n/typeorm": "catalog:",
|
||||
"jest-mock-extended": "^3.0.4",
|
||||
"n8n-workflow": "workspace:*",
|
||||
"reflect-metadata": "catalog:",
|
||||
"uuid": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/typescript-config": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Project, User, ProjectRelation } from '@n8n/db';
|
||||
import { ProjectRelationRepository, ProjectRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { AssignableProjectRole } from '@n8n/permissions';
|
||||
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||
|
||||
import { randomName } from '../random';
|
||||
|
||||
export const linkUserToProject = async (
|
||||
user: User,
|
||||
project: Project,
|
||||
role: AssignableProjectRole,
|
||||
) => {
|
||||
const projectRelationRepository = Container.get(ProjectRelationRepository);
|
||||
await projectRelationRepository.save(
|
||||
projectRelationRepository.create({
|
||||
projectId: project.id,
|
||||
userId: user.id,
|
||||
role: { slug: role },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const createTeamProject = async (name?: string, adminUser?: User) => {
|
||||
const projectRepository = Container.get(ProjectRepository);
|
||||
const project = await projectRepository.save(
|
||||
projectRepository.create({
|
||||
name: name ?? randomName(),
|
||||
type: 'team',
|
||||
creatorId: adminUser?.id,
|
||||
}),
|
||||
);
|
||||
|
||||
if (adminUser) {
|
||||
await linkUserToProject(adminUser, project, 'project:admin');
|
||||
}
|
||||
|
||||
return project;
|
||||
};
|
||||
|
||||
export async function getProjectByNameOrFail(name: string) {
|
||||
return await Container.get(ProjectRepository).findOneOrFail({ where: { name } });
|
||||
}
|
||||
|
||||
export const getPersonalProject = async (user: User): Promise<Project> => {
|
||||
return await Container.get(ProjectRepository).findOneOrFail({
|
||||
where: {
|
||||
projectRelations: {
|
||||
userId: user.id,
|
||||
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||
},
|
||||
type: 'personal',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const findProject = async (id: string): Promise<Project> => {
|
||||
return await Container.get(ProjectRepository).findOneOrFail({
|
||||
where: { id },
|
||||
});
|
||||
};
|
||||
|
||||
export const getProjectRelations = async ({
|
||||
projectId,
|
||||
userId,
|
||||
role,
|
||||
}: Partial<ProjectRelation>): Promise<ProjectRelation[]> => {
|
||||
return await Container.get(ProjectRelationRepository).find({
|
||||
where: { projectId, userId, role },
|
||||
relations: { role: true },
|
||||
});
|
||||
};
|
||||
|
||||
export const getProjectRoleForUser = async (
|
||||
projectId: string,
|
||||
userId: string,
|
||||
): Promise<AssignableProjectRole | undefined> => {
|
||||
return (
|
||||
await Container.get(ProjectRelationRepository).findOne({
|
||||
where: { projectId, userId },
|
||||
relations: { role: true },
|
||||
})
|
||||
)?.role?.slug;
|
||||
};
|
||||
|
||||
export const getAllProjectRelations = async ({
|
||||
projectId,
|
||||
}: Partial<ProjectRelation>): Promise<ProjectRelation[]> => {
|
||||
return await Container.get(ProjectRelationRepository).find({
|
||||
where: { projectId },
|
||||
relations: { role: true },
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { SharedWorkflow, IWorkflowDb, WorkflowPublishHistory, WorkflowHistory } from '@n8n/db';
|
||||
import {
|
||||
Project,
|
||||
User,
|
||||
ProjectRepository,
|
||||
SharedWorkflowRepository,
|
||||
WorkflowRepository,
|
||||
WorkflowHistoryRepository,
|
||||
WorkflowPublishHistoryRepository,
|
||||
} from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { WorkflowSharingRole } from '@n8n/permissions';
|
||||
import type { DeepPartial } from '@n8n/typeorm';
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export function newWorkflow(attributes: Partial<IWorkflowDb> = {}): IWorkflowDb {
|
||||
const { active, isArchived, name, nodes, connections, versionId, settings } = attributes;
|
||||
|
||||
const workflowEntity = Container.get(WorkflowRepository).create({
|
||||
active: active ?? false,
|
||||
isArchived: isArchived ?? false,
|
||||
name: name ?? 'test workflow',
|
||||
nodes: nodes ?? [
|
||||
{
|
||||
id: 'uuid-1234',
|
||||
name: 'Schedule Trigger',
|
||||
parameters: {},
|
||||
position: [-20, 260],
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
typeVersion: 1,
|
||||
},
|
||||
],
|
||||
connections: connections ?? {},
|
||||
versionId: versionId ?? uuid(),
|
||||
settings: settings ?? {},
|
||||
...attributes,
|
||||
});
|
||||
|
||||
return workflowEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a workflow in the DB (without a trigger) and optionally assign it to a user.
|
||||
* @param attributes workflow attributes
|
||||
* @param user user to assign the workflow to
|
||||
*/
|
||||
export async function createWorkflow(
|
||||
attributes: Partial<IWorkflowDb> = {},
|
||||
userOrProject?: User | Project,
|
||||
) {
|
||||
const workflow = await Container.get(WorkflowRepository).save(newWorkflow(attributes));
|
||||
|
||||
if (userOrProject instanceof User) {
|
||||
const user = userOrProject;
|
||||
const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(user.id);
|
||||
await Container.get(SharedWorkflowRepository).save(
|
||||
Container.get(SharedWorkflowRepository).create({
|
||||
project,
|
||||
workflow,
|
||||
role: 'workflow:owner',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (userOrProject instanceof Project) {
|
||||
const project = userOrProject;
|
||||
await Container.get(SharedWorkflowRepository).save(
|
||||
Container.get(SharedWorkflowRepository).create({
|
||||
project,
|
||||
workflow,
|
||||
role: 'workflow:owner',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
export async function createManyWorkflows(
|
||||
amount: number,
|
||||
attributes: Partial<IWorkflowDb> = {},
|
||||
user?: User,
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const workflowRequests = [...Array(amount)].map(
|
||||
async (_) => await createWorkflow(attributes, user),
|
||||
);
|
||||
return await Promise.all(workflowRequests);
|
||||
}
|
||||
|
||||
export async function createManyActiveWorkflows(
|
||||
amount: number,
|
||||
attributes: Partial<IWorkflowDb> = {},
|
||||
userOrProject?: User | Project,
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const workflowRequests = [...Array(amount)].map(
|
||||
async (_) => await createActiveWorkflow(attributes, userOrProject),
|
||||
);
|
||||
return await Promise.all(workflowRequests);
|
||||
}
|
||||
|
||||
export async function shareWorkflowWithUsers(workflow: IWorkflowBase, users: User[]) {
|
||||
const sharedWorkflows: Array<DeepPartial<SharedWorkflow>> = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
|
||||
user.id,
|
||||
);
|
||||
return {
|
||||
projectId: project.id,
|
||||
workflowId: workflow.id,
|
||||
role: 'workflow:editor',
|
||||
};
|
||||
}),
|
||||
);
|
||||
return await Container.get(SharedWorkflowRepository).save(sharedWorkflows);
|
||||
}
|
||||
|
||||
export async function shareWorkflowWithProjects(
|
||||
workflow: IWorkflowBase,
|
||||
projectsWithRole: Array<{ project: Project; role?: WorkflowSharingRole }>,
|
||||
) {
|
||||
const newSharedWorkflow = await Promise.all(
|
||||
projectsWithRole.map(async ({ project, role }) => {
|
||||
return Container.get(SharedWorkflowRepository).create({
|
||||
workflowId: workflow.id,
|
||||
role: role ?? 'workflow:editor',
|
||||
projectId: project.id,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return await Container.get(SharedWorkflowRepository).save(newSharedWorkflow);
|
||||
}
|
||||
|
||||
export async function getWorkflowSharing(workflow: IWorkflowBase) {
|
||||
return await Container.get(SharedWorkflowRepository).find({
|
||||
where: { workflowId: workflow.id },
|
||||
relations: { project: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a workflow in the DB (with a trigger) and optionally assign it to a user.
|
||||
* @param user user to assign the workflow to
|
||||
*/
|
||||
export async function createWorkflowWithTrigger(
|
||||
attributes: Partial<IWorkflowDb> = {},
|
||||
userOrProject?: User | Project,
|
||||
) {
|
||||
const workflow = await createWorkflow(
|
||||
{
|
||||
nodes: [
|
||||
{
|
||||
id: 'uuid-1',
|
||||
parameters: {},
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [240, 300],
|
||||
},
|
||||
{
|
||||
id: 'uuid-2',
|
||||
parameters: { triggerTimes: { item: [{ mode: 'everyMinute' }] } },
|
||||
name: 'Cron',
|
||||
type: 'n8n-nodes-base.cron',
|
||||
typeVersion: 1,
|
||||
position: [500, 300],
|
||||
},
|
||||
{
|
||||
id: 'uuid-3',
|
||||
parameters: { options: {} },
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 1,
|
||||
position: [780, 300],
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
Cron: { main: [[{ node: 'Set', type: NodeConnectionTypes.Main, index: 0 }]] },
|
||||
},
|
||||
...attributes,
|
||||
},
|
||||
userOrProject,
|
||||
);
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a workflow in the DB and create its workflow history.
|
||||
* @param attributes workflow attributes
|
||||
* @param userOrProject user or project to assign the workflow to
|
||||
*/
|
||||
export async function createWorkflowWithHistory(
|
||||
attributes: Partial<IWorkflowDb> = {},
|
||||
userOrProject?: User | Project,
|
||||
withPublishHistory?: Partial<WorkflowPublishHistory>,
|
||||
) {
|
||||
const workflow = await createWorkflow(attributes, userOrProject);
|
||||
|
||||
// Create workflow history for the initial version
|
||||
await createWorkflowHistory(workflow, userOrProject, withPublishHistory);
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a workflow with trigger in the DB and create its workflow history.
|
||||
* @param attributes workflow attributes
|
||||
* @param user user to assign the workflow to
|
||||
*/
|
||||
export async function createWorkflowWithTriggerAndHistory(
|
||||
attributes: Partial<IWorkflowDb> = {},
|
||||
userOrProject?: User | Project,
|
||||
withPublishHistory?: Partial<WorkflowPublishHistory>,
|
||||
) {
|
||||
const workflow = await createWorkflowWithTrigger(attributes, userOrProject);
|
||||
|
||||
// Create workflow history for the initial version
|
||||
await createWorkflowHistory(workflow, userOrProject, withPublishHistory);
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
export async function getAllWorkflows() {
|
||||
return await Container.get(WorkflowRepository).find();
|
||||
}
|
||||
|
||||
export async function getAllSharedWorkflows() {
|
||||
return await Container.get(SharedWorkflowRepository).find();
|
||||
}
|
||||
|
||||
export const getWorkflowById = async (id: string) =>
|
||||
await Container.get(WorkflowRepository).findOneBy({ id });
|
||||
|
||||
/**
|
||||
* Create a workflow history record for a workflow
|
||||
* @param workflow workflow to create history for
|
||||
* @param user user who created the version (optional)
|
||||
* @param withPublishHistory publish history to create (optional)
|
||||
* @param autosaved whether this is an autosave (optional)
|
||||
*/
|
||||
export async function createWorkflowHistory(
|
||||
workflow: IWorkflowDb,
|
||||
userOrProject?: User | Project,
|
||||
withPublishHistory?: Partial<WorkflowPublishHistory>,
|
||||
overrides: Partial<WorkflowHistory> = {},
|
||||
): Promise<void> {
|
||||
const authors =
|
||||
userOrProject instanceof User
|
||||
? userOrProject.firstName && userOrProject.lastName
|
||||
? `${userOrProject.firstName} ${userOrProject.lastName}`
|
||||
: 'Test User'
|
||||
: 'Test User';
|
||||
|
||||
await Container.get(WorkflowHistoryRepository).insert({
|
||||
workflowId: workflow.id,
|
||||
versionId: workflow.versionId,
|
||||
nodes: workflow.nodes,
|
||||
connections: workflow.connections,
|
||||
authors,
|
||||
autosaved: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
if (withPublishHistory) {
|
||||
// We wait a millisecond as createdAt order is often relevant for the publishing history
|
||||
await new Promise((res) => setTimeout(res, 1));
|
||||
await Container.get(WorkflowPublishHistoryRepository).insert({
|
||||
workflowId: workflow.id,
|
||||
versionId: workflow.versionId,
|
||||
event: 'activated',
|
||||
userId: userOrProject instanceof User ? userOrProject.id : undefined,
|
||||
...withPublishHistory,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active version for a workflow
|
||||
* @param workflowId workflow ID
|
||||
* @param versionId version ID to set as active
|
||||
*/
|
||||
export async function setActiveVersion(workflowId: string, versionId: string): Promise<void> {
|
||||
await Container.get(WorkflowRepository)
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ activeVersionId: versionId })
|
||||
.where('id = :workflowId', { workflowId })
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an active workflow with trigger, history, and activeVersionId set to the current version.
|
||||
* This simulates a workflow that has been activated and is running.
|
||||
* @param attributes workflow attributes
|
||||
* @param user user to assign the workflow to
|
||||
*/
|
||||
export async function createActiveWorkflow(
|
||||
attributes: Partial<IWorkflowDb> = {},
|
||||
userOrProject?: User | Project,
|
||||
) {
|
||||
const workflow = await createWorkflowWithTriggerAndHistory(
|
||||
{ active: true, ...attributes },
|
||||
userOrProject,
|
||||
{},
|
||||
);
|
||||
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
|
||||
workflow.activeVersionId = workflow.versionId;
|
||||
|
||||
return workflow;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
export const mockLogger = (): Logger =>
|
||||
mock<Logger>({ scoped: jest.fn().mockReturnValue(mock<Logger>()) });
|
||||
|
||||
export * from './random';
|
||||
export * as testDb from './test-db';
|
||||
export * as testModules from './test-modules';
|
||||
export * from './db/workflows';
|
||||
export * from './db/projects';
|
||||
export * from './mocking';
|
||||
export * from './migration-test-helpers';
|
||||
@@ -0,0 +1,205 @@
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { type DatabaseType, DbConnection, type Migration } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource, type ObjectLiteral, type QueryRunner } from '@n8n/typeorm';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
async function reinitializeDataConnection(): Promise<void> {
|
||||
const dbConnection = Container.get(DbConnection);
|
||||
await dbConnection.close();
|
||||
await dbConnection.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the properly qualified migrations table name for the current database.
|
||||
*/
|
||||
function getMigrationsTableName(): string {
|
||||
const globalConfig = Container.get(GlobalConfig);
|
||||
const dbType = globalConfig.database.type;
|
||||
const tablePrefix = globalConfig.database.tablePrefix;
|
||||
|
||||
if (dbType === 'postgresdb') {
|
||||
const schema = globalConfig.database.postgresdb.schema;
|
||||
return `${schema}."${tablePrefix}migrations"`;
|
||||
}
|
||||
return `"${tablePrefix}migrations"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test migration context with database-specific helpers (similar to MigrationContext).
|
||||
*/
|
||||
export interface TestMigrationContext {
|
||||
queryRunner: QueryRunner;
|
||||
tablePrefix: string;
|
||||
dbType: DatabaseType;
|
||||
isSqlite: boolean;
|
||||
isPostgres: boolean;
|
||||
escape: {
|
||||
columnName(name: string): string;
|
||||
tableName(name: string): string;
|
||||
indexName(name: string): string;
|
||||
};
|
||||
runQuery: <T = unknown>(sql: string, namedParameters?: ObjectLiteral) => Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test migration context with database-specific helpers.
|
||||
* Provides the same utilities that migrations have access to.
|
||||
*/
|
||||
export function createTestMigrationContext(dataSource: DataSource): TestMigrationContext {
|
||||
const globalConfig = Container.get(GlobalConfig);
|
||||
const dbType = globalConfig.database.type;
|
||||
const tablePrefix = globalConfig.database.tablePrefix;
|
||||
const queryRunner = dataSource.createQueryRunner();
|
||||
|
||||
return {
|
||||
queryRunner,
|
||||
tablePrefix,
|
||||
dbType,
|
||||
isSqlite: dbType === 'sqlite',
|
||||
isPostgres: dbType === 'postgresdb',
|
||||
escape: {
|
||||
columnName: (name) => queryRunner.connection.driver.escape(name),
|
||||
tableName: (name) => queryRunner.connection.driver.escape(`${tablePrefix}${name}`),
|
||||
indexName: (name) => queryRunner.connection.driver.escape(`IDX_${tablePrefix}${name}`),
|
||||
},
|
||||
runQuery: async <T>(sql: string, namedParameters?: ObjectLiteral) => {
|
||||
if (namedParameters) {
|
||||
if (dbType === 'postgresdb') {
|
||||
// For PostgreSQL, convert named parameters to positional ($1, $2, etc.)
|
||||
// This handles JSON columns properly which don't work well with TypeORM's escapeQueryWithParameters
|
||||
// Use negative lookbehind to avoid matching PostgreSQL's :: cast operator
|
||||
let paramIndex = 1;
|
||||
const paramValues: unknown[] = [];
|
||||
const convertedSql = sql.replace(/(?<!:):(\w+)/g, (_, paramName: string) => {
|
||||
paramValues.push(namedParameters[paramName] as unknown);
|
||||
return `$${paramIndex++}`;
|
||||
});
|
||||
return (await queryRunner.query(convertedSql, paramValues)) as T;
|
||||
} else {
|
||||
// For MySQL/SQLite, use TypeORM's escapeQueryWithParameters
|
||||
const [query, parameters] = queryRunner.connection.driver.escapeQueryWithParameters(
|
||||
sql,
|
||||
namedParameters,
|
||||
{},
|
||||
);
|
||||
return (await queryRunner.query(query, parameters)) as T;
|
||||
}
|
||||
} else {
|
||||
return (await queryRunner.query(sql)) as T;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database and run all migrations up to (but not including) the specified migration.
|
||||
* Useful for testing data transformations by inserting test data before a migration runs.
|
||||
*
|
||||
* @param beforeMigrationName - The class name of the migration to stop before (e.g., 'AddUserRole1234567890')
|
||||
* @throws {UnexpectedError} If the migration is not found or database is not initialized
|
||||
*/
|
||||
export async function initDbUpToMigration(beforeMigrationName: string): Promise<void> {
|
||||
const dataSource = Container.get(DataSource);
|
||||
|
||||
if (!Array.isArray(dataSource.options.migrations)) {
|
||||
throw new UnexpectedError('Database migrations are not an array');
|
||||
}
|
||||
|
||||
const allMigrations = dataSource.options.migrations as Migration[];
|
||||
const targetIndex = allMigrations.findIndex((m) => m.name === beforeMigrationName);
|
||||
|
||||
if (targetIndex === -1) {
|
||||
throw new UnexpectedError(`Migration "${beforeMigrationName}" not found`);
|
||||
}
|
||||
|
||||
// Temporarily replace migrations array with subset
|
||||
const migrationsToRun = allMigrations.slice(0, targetIndex);
|
||||
(dataSource.options as { migrations: Migration[] }).migrations = migrationsToRun;
|
||||
|
||||
try {
|
||||
// Need to reinitialize the data source to rebuild the migrations
|
||||
await reinitializeDataConnection();
|
||||
// Run migrations
|
||||
await Container.get(DbConnection).migrate();
|
||||
} finally {
|
||||
// Restore full migrations array
|
||||
(dataSource.options as { migrations: Migration[] }).migrations = allMigrations;
|
||||
// Need to reinitialize the data source to rebuild the migrations
|
||||
await reinitializeDataConnection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the last single migration down.
|
||||
* Useful for testing the down path of a specific migration after inserting test data.
|
||||
*/
|
||||
export async function undoLastSingleMigration(): Promise<void> {
|
||||
const dataSource = Container.get(DataSource);
|
||||
|
||||
// Get the last executed migration from the migrations table
|
||||
const executedMigrations = await dataSource.query<Array<{ name: string }>>(
|
||||
`SELECT * FROM ${getMigrationsTableName()} ORDER BY timestamp DESC LIMIT 1`,
|
||||
);
|
||||
|
||||
if (executedMigrations.length === 0) {
|
||||
throw new UnexpectedError('No migrations found to undo');
|
||||
}
|
||||
|
||||
const lastMigrationName = executedMigrations[0].name;
|
||||
|
||||
// Find the migration class by name
|
||||
type MigrationConstructor = new () => { transaction?: false };
|
||||
type MigrationClass = MigrationConstructor & {
|
||||
prototype?: { transaction?: false; __n8n_wrapped?: boolean };
|
||||
name: string;
|
||||
};
|
||||
const migration = (dataSource.options.migrations as MigrationClass[]).find(
|
||||
(m) => m.name === lastMigrationName,
|
||||
);
|
||||
|
||||
// Create an instance to check the transaction property (class fields are on instances, not prototypes)
|
||||
let hasTransactionDisabled = false;
|
||||
if (migration) {
|
||||
const instance = new migration();
|
||||
hasTransactionDisabled = instance.transaction === false;
|
||||
}
|
||||
|
||||
// Use transaction: 'none' for migrations with transaction = false, otherwise use 'each'
|
||||
await dataSource.undoLastMigration({
|
||||
transaction: hasTransactionDisabled ? 'none' : 'each',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single migration by name.
|
||||
* Useful for testing a specific migration after inserting test data.
|
||||
*
|
||||
* @param migrationName - The class name of the migration to run (e.g., 'AddUserRole1234567890')
|
||||
* @throws {UnexpectedError} If the migration is not found or database is not initialized
|
||||
*/
|
||||
export async function runSingleMigration(migrationName: string): Promise<void> {
|
||||
const dataSource = Container.get(DataSource);
|
||||
|
||||
const allMigrations = dataSource.options.migrations as Migration[];
|
||||
const migration = allMigrations.find((m) => m.name === migrationName);
|
||||
|
||||
if (!migration) {
|
||||
throw new UnexpectedError(`Migration "${migrationName}" not found`);
|
||||
}
|
||||
|
||||
// Temporarily replace migrations array with only the target migration
|
||||
(dataSource.options as { migrations: Migration[] }).migrations = [migration];
|
||||
|
||||
try {
|
||||
// Need to reinitialize the data source to rebuild the migrations
|
||||
await reinitializeDataConnection();
|
||||
// Run migrations
|
||||
await Container.get(DbConnection).migrate();
|
||||
} finally {
|
||||
// Restore full migrations array
|
||||
(dataSource.options as { migrations: Migration[] }).migrations = allMigrations;
|
||||
// Need to reinitialize the data source to rebuild the migrations
|
||||
await reinitializeDataConnection();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Container, type Constructable } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
export const mockInstance = <T>(
|
||||
serviceClass: Constructable<T>,
|
||||
data?: Parameters<typeof mock<T>>[0],
|
||||
) => {
|
||||
const instance = mock<T>(data);
|
||||
Container.set(serviceClass, instance);
|
||||
return instance;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { MIN_PASSWORD_CHAR_LENGTH, MAX_PASSWORD_CHAR_LENGTH } from '@n8n/constants';
|
||||
import { randomInt, randomString, UPPERCASE_LETTERS } from 'n8n-workflow';
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export type CredentialPayload = {
|
||||
name: string;
|
||||
type: string;
|
||||
data: ICredentialDataDecryptedObject;
|
||||
isManaged?: boolean;
|
||||
isGlobal?: boolean;
|
||||
isResolvable?: boolean;
|
||||
};
|
||||
|
||||
export const randomApiKey = () => `n8n_api_${randomString(40)}`;
|
||||
|
||||
export const chooseRandomly = <T>(array: T[]) => array[randomInt(array.length)];
|
||||
|
||||
const randomUppercaseLetter = () => chooseRandomly(UPPERCASE_LETTERS.split(''));
|
||||
|
||||
export const randomValidPassword = () =>
|
||||
randomString(MIN_PASSWORD_CHAR_LENGTH, MAX_PASSWORD_CHAR_LENGTH - 2) +
|
||||
randomUppercaseLetter() +
|
||||
randomInt(10);
|
||||
|
||||
export const randomInvalidPassword = () =>
|
||||
chooseRandomly([
|
||||
randomString(1, MIN_PASSWORD_CHAR_LENGTH - 1),
|
||||
randomString(MAX_PASSWORD_CHAR_LENGTH + 2, MAX_PASSWORD_CHAR_LENGTH + 100),
|
||||
'abcdefgh', // valid length, no number, no uppercase
|
||||
'abcdefg1', // valid length, has number, no uppercase
|
||||
'abcdefgA', // valid length, no number, has uppercase
|
||||
'abcdefA', // invalid length, no number, has uppercase
|
||||
'abcdef1', // invalid length, has number, no uppercase
|
||||
'abcdeA1', // invalid length, has number, has uppercase
|
||||
'abcdefg', // invalid length, no number, no uppercase
|
||||
]);
|
||||
|
||||
const POPULAR_TOP_LEVEL_DOMAINS = ['com', 'org', 'net', 'io', 'edu'];
|
||||
|
||||
const randomTopLevelDomain = () => chooseRandomly(POPULAR_TOP_LEVEL_DOMAINS);
|
||||
|
||||
export const randomName = () => randomString(4, 8).toLowerCase();
|
||||
|
||||
export const randomEmail = () => `${randomName()}@${randomName()}.${randomTopLevelDomain()}`;
|
||||
|
||||
export const randomCredentialPayload = ({
|
||||
isManaged = false,
|
||||
isGlobal,
|
||||
isResolvable,
|
||||
type,
|
||||
}: {
|
||||
isManaged?: boolean;
|
||||
isGlobal?: boolean;
|
||||
isResolvable?: boolean;
|
||||
type?: string;
|
||||
} = {}): CredentialPayload => {
|
||||
const payload: CredentialPayload = {
|
||||
name: randomName(),
|
||||
type: type ?? 'githubApi',
|
||||
data: { accessToken: randomString(6, 16) },
|
||||
isManaged,
|
||||
};
|
||||
|
||||
// Only include optional fields if they have defined values
|
||||
if (isGlobal !== undefined) payload.isGlobal = isGlobal;
|
||||
if (isResolvable !== undefined) payload.isResolvable = isResolvable;
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const randomCredentialPayloadWithOauthTokenData = ({
|
||||
isManaged = false,
|
||||
}: { isManaged?: boolean } = {}): CredentialPayload => ({
|
||||
name: randomName(),
|
||||
type: randomName(),
|
||||
data: { accessToken: randomString(6, 16), oauthTokenData: { access_token: randomString(6, 16) } },
|
||||
isManaged,
|
||||
});
|
||||
|
||||
export const uniqueId = () => uuid();
|
||||
@@ -0,0 +1,143 @@
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { entities } from '@n8n/db';
|
||||
import { AuthRolesService, DbConnection, DbConnectionOptions } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { DataSourceOptions } from '@n8n/typeorm';
|
||||
import { DataSource as Connection } from '@n8n/typeorm';
|
||||
import assert from 'assert';
|
||||
import { randomString } from 'n8n-workflow';
|
||||
|
||||
export const testDbPrefix = 'n8n_test_';
|
||||
let isInitialized = false;
|
||||
let testDbName: string | undefined;
|
||||
let originalDatabase: string | undefined;
|
||||
|
||||
/**
|
||||
* Generate options for a bootstrap DB connection, to create and drop test databases.
|
||||
*/
|
||||
export const getBootstrapDBOptions = (): DataSourceOptions => {
|
||||
const globalConfig = Container.get(GlobalConfig);
|
||||
assert(globalConfig.database.type === 'postgresdb', 'Database type must be postgresdb');
|
||||
|
||||
return {
|
||||
type: 'postgres',
|
||||
...Container.get(DbConnectionOptions).getPostgresOverrides(),
|
||||
database: globalConfig.database.postgresdb.database,
|
||||
entityPrefix: globalConfig.database.tablePrefix,
|
||||
schema: globalConfig.database.postgresdb.schema,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize one test DB per suite run, with bootstrap connection if needed.
|
||||
*/
|
||||
export async function init() {
|
||||
if (isInitialized) return;
|
||||
|
||||
const globalConfig = Container.get(GlobalConfig);
|
||||
const dbType = globalConfig.database.type;
|
||||
testDbName = `${testDbPrefix}${randomString(6, 10).toLowerCase()}_${Date.now()}`;
|
||||
|
||||
if (dbType === 'postgresdb') {
|
||||
originalDatabase = globalConfig.database.postgresdb.database;
|
||||
const bootstrapPostgres = await new Connection(getBootstrapDBOptions()).initialize();
|
||||
await bootstrapPostgres.query(`CREATE DATABASE ${testDbName}`);
|
||||
await bootstrapPostgres.destroy();
|
||||
|
||||
globalConfig.database.postgresdb.database = testDbName;
|
||||
}
|
||||
|
||||
const dbConnection = Container.get(DbConnection);
|
||||
await dbConnection.init();
|
||||
await dbConnection.migrate();
|
||||
|
||||
await Container.get(AuthRolesService).init();
|
||||
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
export function isReady() {
|
||||
const { connectionState } = Container.get(DbConnection);
|
||||
return connectionState.connected && connectionState.migrated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop test DB, closing bootstrap connection if existing.
|
||||
*/
|
||||
export async function terminate() {
|
||||
const dbConnection = Container.get(DbConnection);
|
||||
await dbConnection.close();
|
||||
dbConnection.connectionState.connected = false;
|
||||
|
||||
if (testDbName && originalDatabase) {
|
||||
const globalConfig = Container.get(GlobalConfig);
|
||||
if (globalConfig.database.type === 'postgresdb') {
|
||||
try {
|
||||
globalConfig.database.postgresdb.database = originalDatabase;
|
||||
const bootstrap = await new Connection(getBootstrapDBOptions()).initialize();
|
||||
await bootstrap.query(`DROP DATABASE IF EXISTS "${testDbName}"`);
|
||||
await bootstrap.destroy();
|
||||
} catch (error) {
|
||||
// Best effort - don't fail tests over cleanup
|
||||
console.warn(`Failed to drop test database "${testDbName}":`, error);
|
||||
}
|
||||
}
|
||||
testDbName = undefined;
|
||||
}
|
||||
|
||||
isInitialized = false;
|
||||
}
|
||||
|
||||
type EntityName =
|
||||
| keyof typeof entities
|
||||
| 'InsightsRaw'
|
||||
| 'InsightsByPeriod'
|
||||
| 'InsightsMetadata'
|
||||
| 'DataTable'
|
||||
| 'DataTableColumn'
|
||||
| 'ChatHubSession'
|
||||
| 'ChatHubMessage'
|
||||
| 'ChatHubAgent'
|
||||
| 'ChatHubTool'
|
||||
| 'OAuthClient'
|
||||
| 'AuthorizationCode'
|
||||
| 'AccessToken'
|
||||
| 'RefreshToken'
|
||||
| 'UserConsent'
|
||||
| 'DynamicCredentialEntry'
|
||||
| 'DynamicCredentialResolver'
|
||||
| 'DynamicCredentialUserEntry';
|
||||
|
||||
/**
|
||||
* Truncate specific DB tables in a test DB.
|
||||
*/
|
||||
export async function truncate(entities: EntityName[]) {
|
||||
const connection = Container.get(Connection);
|
||||
|
||||
// Collect junction tables to clean
|
||||
const junctionTablesToClean = new Set<string>();
|
||||
|
||||
// Find all junction tables associated with the entities being truncated
|
||||
for (const name of entities) {
|
||||
try {
|
||||
const metadata = connection.getMetadata(name);
|
||||
for (const relation of metadata.manyToManyRelations) {
|
||||
if (relation.junctionEntityMetadata) {
|
||||
const junctionTableName = relation.junctionEntityMetadata.tablePath;
|
||||
junctionTablesToClean.add(junctionTableName);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
|
||||
// Clean junction tables first (since they reference the entities)
|
||||
for (const tableName of junctionTablesToClean) {
|
||||
await connection.query(`DELETE FROM ${tableName}`);
|
||||
}
|
||||
|
||||
for (const name of entities) {
|
||||
await connection.getRepository(name).delete({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ModuleRegistry } from '@n8n/backend-common';
|
||||
import type { ModuleName } from '@n8n/backend-common';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
export async function loadModules(moduleNames: ModuleName[]) {
|
||||
await Container.get(ModuleRegistry).loadModules(moduleNames);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/__tests__/**"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@n8n/typescript-config/tsconfig.common.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"types": ["node", "jest"],
|
||||
"baseUrl": "src",
|
||||
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user