import { createWorkflow, testDb, mockInstance, createActiveWorkflow, } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { WorkflowHistoryRepository, WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { In } from '@n8n/typeorm'; import { DateTime } from 'luxon'; import { License } from '@/license'; import { WorkflowHistoryManager } from '@/workflows/workflow-history/workflow-history-manager'; import { createManyWorkflowHistoryItems } from './shared/db/workflow-history'; describe('Workflow History Manager', () => { const license = mockInstance(License); let repo: WorkflowHistoryRepository; let manager: WorkflowHistoryManager; let globalConfig: GlobalConfig; beforeAll(async () => { await testDb.init(); repo = Container.get(WorkflowHistoryRepository); manager = Container.get(WorkflowHistoryManager); globalConfig = Container.get(GlobalConfig); }); beforeEach(async () => { await testDb.truncate(['WorkflowEntity', 'WorkflowHistory', 'WorkflowPublishHistory']); jest.clearAllMocks(); globalConfig.workflowHistory.pruneTime = -1; license.getWorkflowHistoryPruneLimit.mockReturnValue(-1); }); afterAll(async () => { await testDb.terminate(); }); test('should prune on interval', () => { const pruneSpy = jest.spyOn(manager, 'prune'); const currentCount = pruneSpy.mock.calls.length; jest.useFakeTimers(); manager.init(); jest.runOnlyPendingTimers(); expect(pruneSpy).toBeCalledTimes(currentCount + 1); jest.runOnlyPendingTimers(); expect(pruneSpy).toBeCalledTimes(currentCount + 2); manager.shutdown(); jest.clearAllTimers(); jest.useRealTimers(); pruneSpy.mockRestore(); }); test('should not prune when both prune times are -1 (infinite)', async () => { await createWorkflowHistory(); await pruneAndAssertCount(); }); test('should prune when config prune time is not -1 (infinite)', async () => { globalConfig.workflowHistory.pruneTime = 24; await createWorkflowHistory(); await pruneAndAssertCount(0); }); test('should prune when license prune time is not -1 (infinite)', async () => { license.getWorkflowHistoryPruneLimit.mockReturnValue(24); await createWorkflowHistory(); await pruneAndAssertCount(0); }); test('should only prune versions older than prune time', async () => { globalConfig.workflowHistory.pruneTime = 24; const recentVersions = await createWorkflowHistory(0); const oldVersions = await createWorkflowHistory(); await pruneAndAssertCount(10, 20); expect( await repo.count({ where: { versionId: In(recentVersions.map((i) => i.versionId)) } }), ).toBe(10); expect( await repo.count({ where: { versionId: In(oldVersions.map((i) => i.versionId)) } }), ).toBe(0); }); test('should not prune current versions', async () => { globalConfig.workflowHistory.pruneTime = 24; const activeWorkflow = await createActiveWorkflow(); const inactiveWorkflow = await createWorkflow(); // Create old history versions for the active workflow const activeWorkflowVersions = await createManyWorkflowHistoryItems( activeWorkflow.id, 5, DateTime.now().minus({ days: 2 }).toJSDate(), ); // Create old history versions for the inactive workflow const inactiveWorkflowVersions = await createManyWorkflowHistoryItems( inactiveWorkflow.id, 5, DateTime.now().minus({ days: 2 }).toJSDate(), ); // Set the current version for each workflow activeWorkflow.versionId = activeWorkflowVersions[0].versionId; inactiveWorkflow.versionId = inactiveWorkflowVersions[0].versionId; const workflowRepo = Container.get(WorkflowRepository); await workflowRepo.save([activeWorkflow, inactiveWorkflow]); await manager.prune(); // Both workflows' current versions should still exist even though they are old expect(await repo.count({ where: { versionId: activeWorkflow.versionId } })).toBe(1); expect(await repo.count({ where: { versionId: inactiveWorkflow.versionId } })).toBe(1); // Other old versions should be deleted const otherVersionIds = [ ...activeWorkflowVersions.slice(1).map((i) => i.versionId), ...inactiveWorkflowVersions.slice(1).map((i) => i.versionId), ]; expect(await repo.count({ where: { versionId: In(otherVersionIds) } })).toBe(0); }); test('should not prune current or active versions when they differ', async () => { globalConfig.workflowHistory.pruneTime = 24; const workflow = await createActiveWorkflow(); // Create old history versions const workflowVersions = await createManyWorkflowHistoryItems( workflow.id, 5, DateTime.now().minus({ days: 2 }).toJSDate(), ); // Set current version to one version and active version to a different version workflow.versionId = workflowVersions[0].versionId; workflow.activeVersionId = workflowVersions[1].versionId; const workflowRepo = Container.get(WorkflowRepository); await workflowRepo.save(workflow); await manager.prune(); // Both current and active versions should still exist even though they are old expect(await repo.count({ where: { versionId: workflow.versionId } })).toBe(1); expect(await repo.count({ where: { versionId: workflow.activeVersionId } })).toBe(1); // Other old versions should be deleted const otherVersionIds = workflowVersions.slice(2).map((i) => i.versionId); expect(await repo.count({ where: { versionId: In(otherVersionIds) } })).toBe(0); }); test('should not prune named versions when license feature is enabled', async () => { globalConfig.workflowHistory.pruneTime = 24; license.isLicensed.mockImplementation((feature: string) => feature === 'feat:namedVersions'); const workflow = await createWorkflow(); const oldDate = DateTime.now().minus({ days: 2 }).toJSDate(); const versions = await createManyWorkflowHistoryItems(workflow.id, 5, oldDate); await repo.update({ versionId: versions[0].versionId }, { name: 'Named Version 1' }); await repo.update( { versionId: versions[1].versionId }, { name: 'Named Version 2', description: 'Version with description' }, ); await repo.update( { versionId: versions[2].versionId }, { name: 'Named Version 3', description: 'Also has description' }, ); // versions[3] and versions[4] remain unnamed await manager.prune(); expect(license.isLicensed).toHaveBeenCalledWith('feat:namedVersions'); // Named versions should be preserved expect(await repo.count({ where: { versionId: versions[0].versionId } })).toBe(1); expect(await repo.count({ where: { versionId: versions[1].versionId } })).toBe(1); expect(await repo.count({ where: { versionId: versions[2].versionId } })).toBe(1); // Unnamed versions should be deleted expect(await repo.count({ where: { versionId: versions[3].versionId } })).toBe(0); expect(await repo.count({ where: { versionId: versions[4].versionId } })).toBe(0); }); test('should prune named versions when license feature is disabled', async () => { globalConfig.workflowHistory.pruneTime = 24; license.isLicensed.mockReturnValue(false); const workflow = await createWorkflow(); const oldDate = DateTime.now().minus({ days: 2 }).toJSDate(); const versions = await createManyWorkflowHistoryItems(workflow.id, 3, oldDate); // Set names on versions await repo.update({ versionId: versions[0].versionId }, { name: 'Named Version 1' }); await repo.update({ versionId: versions[1].versionId }, { name: 'Named Version 2' }); await repo.update( { versionId: versions[2].versionId }, { name: 'Named Version 3', description: 'Version with description' }, ); await manager.prune(); // All versions should be deleted expect(await repo.count({ where: { versionId: In(versions.map((v) => v.versionId)) } })).toBe( 0, ); }); const createWorkflowHistory = async (ageInDays = 2) => { const workflow = await createWorkflow(); const time = DateTime.now().minus({ days: ageInDays }).toJSDate(); return await createManyWorkflowHistoryItems(workflow.id, 10, time); }; const pruneAndAssertCount = async (finalCount = 10, initialCount = 10) => { expect(await repo.count()).toBe(initialCount); const deleteSpy = jest.spyOn(repo, 'deleteEarlierThanExceptCurrentAndActive'); await manager.prune(); if (initialCount === finalCount) { expect(deleteSpy).not.toBeCalled(); } else { expect(deleteSpy).toBeCalled(); } deleteSpy.mockRestore(); expect(await repo.count()).toBe(finalCount); }; });