mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-26 06:17:21 +02:00
351 lines
10 KiB
TypeScript
351 lines
10 KiB
TypeScript
import { Logger } from '@n8n/backend-common';
|
|
import {
|
|
testDb,
|
|
createWorkflow,
|
|
createWorkflowHistory,
|
|
setActiveVersion,
|
|
} from '@n8n/backend-test-utils';
|
|
import { WorkflowsConfig } from '@n8n/config';
|
|
import type { IWorkflowDb } from '@n8n/db';
|
|
import { WorkflowDependencyRepository, WorkflowRepository } from '@n8n/db';
|
|
import { Container } from '@n8n/di';
|
|
import { retryUntil } from '@test-integration/retry-until';
|
|
import { ErrorReporter, Tracing } from 'n8n-core';
|
|
import type { INode } from 'n8n-workflow';
|
|
import { v4 as uuid } from 'uuid';
|
|
|
|
import { createOwner } from '../shared/db/users';
|
|
|
|
import { EventService } from '@/events/event.service';
|
|
import { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
|
|
|
|
let workflowIndexService: WorkflowIndexService;
|
|
let eventService: EventService;
|
|
let workflowRepository: WorkflowRepository;
|
|
let workflowDependencyRepository: WorkflowDependencyRepository;
|
|
|
|
beforeAll(async () => {
|
|
await testDb.init();
|
|
|
|
// Get real instances from the container
|
|
workflowRepository = Container.get(WorkflowRepository);
|
|
workflowDependencyRepository = Container.get(WorkflowDependencyRepository);
|
|
eventService = Container.get(EventService);
|
|
|
|
// Create the WorkflowIndexService with real dependencies
|
|
workflowIndexService = new WorkflowIndexService(
|
|
workflowDependencyRepository,
|
|
workflowRepository,
|
|
eventService,
|
|
Container.get(Logger),
|
|
Container.get(ErrorReporter),
|
|
Container.get(Tracing),
|
|
Container.get(WorkflowsConfig),
|
|
);
|
|
|
|
// Initialize the service to register event listeners
|
|
workflowIndexService.init();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await testDb.truncate(['WorkflowEntity', 'WorkflowDependency', 'WorkflowHistory']);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await testDb.terminate();
|
|
});
|
|
|
|
describe('WorkflowIndexService Integration', () => {
|
|
const createUserPayload = (owner: Awaited<ReturnType<typeof createOwner>>) => ({
|
|
id: owner.id,
|
|
email: owner.email,
|
|
firstName: owner.firstName,
|
|
lastName: owner.lastName,
|
|
role: { slug: owner.role.slug },
|
|
});
|
|
|
|
/**
|
|
* Creates a workflow with draft content, indexes it, then creates and indexes
|
|
* a published version with different content.
|
|
* Returns the workflow (with published nodes) and the published version ID.
|
|
*/
|
|
async function createAndIndexDraftAndPublishedWorkflow(
|
|
owner: Awaited<ReturnType<typeof createOwner>>,
|
|
) {
|
|
const draftWorkflow = await createWorkflow({
|
|
name: 'Workflow with Draft and Published',
|
|
nodes: [
|
|
{
|
|
id: 'node-1',
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
typeVersion: 1,
|
|
position: [250, 300] as [number, number],
|
|
parameters: {},
|
|
},
|
|
],
|
|
});
|
|
|
|
// Index the draft version
|
|
eventService.emit('workflow-created', {
|
|
user: createUserPayload(owner),
|
|
workflow: draftWorkflow,
|
|
publicApi: false,
|
|
projectId: uuid(),
|
|
projectType: 'personal',
|
|
});
|
|
|
|
await retryUntil(async () => {
|
|
const deps = await workflowDependencyRepository.find({
|
|
where: { workflowId: draftWorkflow.id },
|
|
});
|
|
expect(deps).toHaveLength(1);
|
|
});
|
|
|
|
// Create and activate a published version with different content
|
|
const publishedVersionId = uuid();
|
|
const publishedNodes = [
|
|
{
|
|
id: 'node-2',
|
|
name: 'Slack',
|
|
type: 'n8n-nodes-base.slack',
|
|
typeVersion: 2,
|
|
position: [250, 300] as [number, number],
|
|
parameters: {},
|
|
},
|
|
];
|
|
|
|
draftWorkflow.active = true;
|
|
draftWorkflow.versionCounter = 2;
|
|
draftWorkflow.nodes = publishedNodes;
|
|
const savedWorkflow = await workflowRepository.save(draftWorkflow);
|
|
|
|
await createWorkflowHistory({
|
|
...savedWorkflow,
|
|
versionId: publishedVersionId,
|
|
nodes: publishedNodes,
|
|
});
|
|
await setActiveVersion(savedWorkflow.id, publishedVersionId);
|
|
savedWorkflow.activeVersionId = publishedVersionId;
|
|
|
|
// Index the published version
|
|
eventService.emit('workflow-activated', {
|
|
user: createUserPayload(owner),
|
|
workflow: savedWorkflow,
|
|
workflowId: savedWorkflow.id,
|
|
publicApi: false,
|
|
});
|
|
|
|
// Wait for both draft and published entries to be indexed with their expected content
|
|
await retryUntil(async () => {
|
|
const deps = await workflowDependencyRepository.find({
|
|
where: { workflowId: savedWorkflow.id },
|
|
});
|
|
expect(deps).toHaveLength(2);
|
|
|
|
const draftDep = deps.find((d) => d.publishedVersionId === null);
|
|
const publishedDep = deps.find((d) => d.publishedVersionId === publishedVersionId);
|
|
expect(draftDep).toBeDefined();
|
|
expect(publishedDep).toBeDefined();
|
|
});
|
|
|
|
return { workflow: savedWorkflow, publishedVersionId };
|
|
}
|
|
|
|
describe('workflow-created event', () => {
|
|
it('should index a new workflow with a single node', async () => {
|
|
const owner = await createOwner();
|
|
const workflowId = uuid();
|
|
const versionId = uuid();
|
|
|
|
const workflow = {
|
|
id: workflowId,
|
|
name: 'Test Workflow',
|
|
active: false,
|
|
activeVersionId: null,
|
|
versionCounter: 1,
|
|
versionId,
|
|
nodes: [
|
|
{
|
|
id: 'node-1',
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
typeVersion: 1,
|
|
position: [250, 300] as [number, number],
|
|
parameters: {},
|
|
},
|
|
],
|
|
connections: {},
|
|
settings: {},
|
|
triggerCount: 0,
|
|
isArchived: false,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
} satisfies IWorkflowDb;
|
|
|
|
const savedWorkflow = await workflowRepository.save(workflow);
|
|
|
|
eventService.emit('workflow-created', {
|
|
user: createUserPayload(owner),
|
|
workflow: savedWorkflow,
|
|
publicApi: false,
|
|
projectId: uuid(),
|
|
projectType: 'personal',
|
|
});
|
|
|
|
await retryUntil(async () => {
|
|
const dependencies = await workflowDependencyRepository.find({
|
|
where: { workflowId },
|
|
});
|
|
|
|
expect(dependencies).toHaveLength(1);
|
|
expect(dependencies[0]).toMatchObject({
|
|
workflowId,
|
|
workflowVersionId: 1,
|
|
dependencyType: 'nodeType',
|
|
dependencyKey: 'n8n-nodes-base.httpRequest',
|
|
dependencyInfo: {
|
|
nodeId: 'node-1',
|
|
nodeVersion: 1,
|
|
},
|
|
indexVersionId: 1,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('workflow-activated event (published version indexing)', () => {
|
|
it('should keep draft and published dependencies separate', async () => {
|
|
const owner = await createOwner();
|
|
const { workflow, publishedVersionId } = await createAndIndexDraftAndPublishedWorkflow(owner);
|
|
|
|
await retryUntil(async () => {
|
|
const allDependencies = await workflowDependencyRepository.find({
|
|
where: { workflowId: workflow.id },
|
|
order: { publishedVersionId: 'ASC' },
|
|
});
|
|
|
|
expect(allDependencies).toHaveLength(2);
|
|
|
|
const draftDep = allDependencies.find((d) => d.publishedVersionId === null);
|
|
expect(draftDep).toMatchObject({
|
|
workflowId: workflow.id,
|
|
publishedVersionId: null,
|
|
dependencyType: 'nodeType',
|
|
dependencyKey: 'n8n-nodes-base.httpRequest',
|
|
dependencyInfo: {
|
|
nodeId: 'node-1',
|
|
nodeVersion: 1,
|
|
},
|
|
});
|
|
|
|
const publishedDep = allDependencies.find((d) => d.publishedVersionId !== null);
|
|
expect(publishedDep).toMatchObject({
|
|
workflowId: workflow.id,
|
|
publishedVersionId,
|
|
dependencyType: 'nodeType',
|
|
dependencyKey: 'n8n-nodes-base.slack',
|
|
dependencyInfo: {
|
|
nodeId: 'node-2',
|
|
nodeVersion: 2,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('buildIndex (server startup re-indexing)', () => {
|
|
const httpRequestNode: INode = {
|
|
id: 'node-1',
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
parameters: {},
|
|
};
|
|
|
|
const webhookNode: INode = {
|
|
id: 'node-2',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
typeVersion: 1,
|
|
position: [200, 0],
|
|
parameters: { path: 'my-webhook' },
|
|
};
|
|
|
|
const manualTriggerNode: INode = {
|
|
id: 'node-3',
|
|
name: 'Manual Trigger',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [400, 0],
|
|
parameters: {},
|
|
};
|
|
|
|
function getDependencyKeys(
|
|
deps: Array<{ dependencyType: string; dependencyKey: string }>,
|
|
type: string,
|
|
) {
|
|
return deps.filter((d) => d.dependencyType === type).map((d) => d.dependencyKey);
|
|
}
|
|
|
|
it('should use published version nodes (not draft) during batch re-indexing', async () => {
|
|
// 1. Create a workflow, publish it, then modify the draft
|
|
const publishedNodes = [httpRequestNode, webhookNode];
|
|
const workflow = await createWorkflow({ nodes: publishedNodes });
|
|
await createWorkflowHistory(workflow);
|
|
await setActiveVersion(workflow.id, workflow.versionId);
|
|
|
|
// 2. Modify the draft nodes (different from published)
|
|
const draftNodes = [manualTriggerNode];
|
|
await workflowRepository.update(workflow.id, {
|
|
nodes: draftNodes,
|
|
versionCounter: 2,
|
|
});
|
|
|
|
// 3. Run buildIndex (simulates server startup re-indexing)
|
|
await workflowIndexService.buildIndex();
|
|
|
|
// 4. Verify the published index was built from published nodes, not draft
|
|
const allDeps = await workflowDependencyRepository.find({
|
|
where: { workflowId: workflow.id },
|
|
order: { dependencyType: 'ASC', dependencyKey: 'ASC' },
|
|
});
|
|
|
|
const draftDeps = allDeps.filter((d) => d.publishedVersionId === null);
|
|
const publishedDeps = allDeps.filter((d) => d.publishedVersionId !== null);
|
|
|
|
// Draft index should reflect the modified nodes
|
|
const draftNodeTypes = getDependencyKeys(draftDeps, 'nodeType').sort();
|
|
expect(draftNodeTypes).toEqual(['n8n-nodes-base.manualTrigger']);
|
|
|
|
// Published index should reflect the original published nodes
|
|
const publishedNodeTypes = getDependencyKeys(publishedDeps, 'nodeType').sort();
|
|
expect(publishedNodeTypes).toEqual(['n8n-nodes-base.httpRequest', 'n8n-nodes-base.webhook']);
|
|
expect(getDependencyKeys(publishedDeps, 'webhookPath')).toContain('my-webhook');
|
|
});
|
|
});
|
|
|
|
describe('workflow-deleted event', () => {
|
|
it('should remove both draft and published index entries', async () => {
|
|
const owner = await createOwner();
|
|
const { workflow } = await createAndIndexDraftAndPublishedWorkflow(owner);
|
|
|
|
// Delete the workflow
|
|
eventService.emit('workflow-deleted', {
|
|
user: createUserPayload(owner),
|
|
workflowId: workflow.id,
|
|
publicApi: false,
|
|
});
|
|
|
|
// Verify all entries are removed
|
|
await retryUntil(async () => {
|
|
const remainingDeps = await workflowDependencyRepository.find({
|
|
where: { workflowId: workflow.id },
|
|
});
|
|
expect(remainingDeps).toHaveLength(0);
|
|
});
|
|
});
|
|
});
|
|
});
|