workflow-versioning-service.test.ts•24.1 kB
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WorkflowVersioningService, type WorkflowVersion, type BackupResult } from '@/services/workflow-versioning-service';
import { NodeRepository } from '@/database/node-repository';
import { N8nApiClient } from '@/services/n8n-api-client';
import { WorkflowValidator } from '@/services/workflow-validator';
import type { Workflow } from '@/types/n8n-api';
vi.mock('@/database/node-repository');
vi.mock('@/services/n8n-api-client');
vi.mock('@/services/workflow-validator');
describe('WorkflowVersioningService', () => {
let service: WorkflowVersioningService;
let mockRepository: NodeRepository;
let mockApiClient: N8nApiClient;
const createMockWorkflow = (id: string, name: string, nodes: any[] = []): Workflow => ({
id,
name,
active: false,
nodes,
connections: {},
settings: {},
createdAt: '2025-01-01T00:00:00.000Z',
updatedAt: '2025-01-01T00:00:00.000Z'
});
const createMockVersion = (versionNumber: number): WorkflowVersion => ({
id: versionNumber,
workflowId: 'workflow-1',
versionNumber,
workflowName: 'Test Workflow',
workflowSnapshot: createMockWorkflow('workflow-1', 'Test Workflow'),
trigger: 'partial_update',
createdAt: '2025-01-01T00:00:00.000Z'
});
beforeEach(() => {
vi.clearAllMocks();
mockRepository = new NodeRepository({} as any);
mockApiClient = new N8nApiClient({ baseUrl: 'http://test', apiKey: 'test-key' });
service = new WorkflowVersioningService(mockRepository, mockApiClient);
});
describe('createBackup', () => {
it('should create a backup with version 1 for new workflow', async () => {
const workflow = createMockWorkflow('workflow-1', 'Test Workflow');
vi.spyOn(mockRepository, 'getWorkflowVersions').mockReturnValue([]);
vi.spyOn(mockRepository, 'createWorkflowVersion').mockReturnValue(1);
vi.spyOn(mockRepository, 'pruneWorkflowVersions').mockReturnValue(0);
const result = await service.createBackup('workflow-1', workflow, {
trigger: 'partial_update'
});
expect(result.versionId).toBe(1);
expect(result.versionNumber).toBe(1);
expect(result.pruned).toBe(0);
expect(result.message).toContain('Backup created (version 1)');
});
it('should increment version number from latest version', async () => {
const workflow = createMockWorkflow('workflow-1', 'Test Workflow');
const existingVersions = [createMockVersion(3), createMockVersion(2)];
vi.spyOn(mockRepository, 'getWorkflowVersions').mockReturnValue(existingVersions);
vi.spyOn(mockRepository, 'createWorkflowVersion').mockReturnValue(4);
vi.spyOn(mockRepository, 'pruneWorkflowVersions').mockReturnValue(0);
const result = await service.createBackup('workflow-1', workflow, {
trigger: 'full_update'
});
expect(result.versionNumber).toBe(4);
expect(mockRepository.createWorkflowVersion).toHaveBeenCalledWith(
expect.objectContaining({
versionNumber: 4
})
);
});
it('should include context in version metadata', async () => {
const workflow = createMockWorkflow('workflow-1', 'Test Workflow');
vi.spyOn(mockRepository, 'getWorkflowVersions').mockReturnValue([]);
vi.spyOn(mockRepository, 'createWorkflowVersion').mockReturnValue(1);
vi.spyOn(mockRepository, 'pruneWorkflowVersions').mockReturnValue(0);
await service.createBackup('workflow-1', workflow, {
trigger: 'autofix',
operations: [{ type: 'updateNode', nodeId: 'node-1' }],
fixTypes: ['expression-format'],
metadata: { testKey: 'testValue' }
});
expect(mockRepository.createWorkflowVersion).toHaveBeenCalledWith(
expect.objectContaining({
trigger: 'autofix',
operations: [{ type: 'updateNode', nodeId: 'node-1' }],
fixTypes: ['expression-format'],
metadata: { testKey: 'testValue' }
})
);
});
it('should auto-prune to 10 versions and report pruned count', async () => {
const workflow = createMockWorkflow('workflow-1', 'Test Workflow');
vi.spyOn(mockRepository, 'getWorkflowVersions').mockReturnValue([createMockVersion(1)]);
vi.spyOn(mockRepository, 'createWorkflowVersion').mockReturnValue(2);
vi.spyOn(mockRepository, 'pruneWorkflowVersions').mockReturnValue(3);
const result = await service.createBackup('workflow-1', workflow, {
trigger: 'partial_update'
});
expect(mockRepository.pruneWorkflowVersions).toHaveBeenCalledWith('workflow-1', 10);
expect(result.pruned).toBe(3);
expect(result.message).toContain('pruned 3 old version(s)');
});
});
describe('getVersionHistory', () => {
it('should return formatted version history', async () => {
const versions = [
createMockVersion(3),
createMockVersion(2),
createMockVersion(1)
];
vi.spyOn(mockRepository, 'getWorkflowVersions').mockReturnValue(versions);
const result = await service.getVersionHistory('workflow-1', 10);
expect(result).toHaveLength(3);
expect(result[0].versionNumber).toBe(3);
expect(result[0].workflowId).toBe('workflow-1');
expect(result[0].size).toBeGreaterThan(0);
});
it('should include operation count when operations exist', async () => {
const versionWithOps: WorkflowVersion = {
...createMockVersion(1),
operations: [{ type: 'updateNode' }, { type: 'addNode' }]
};
vi.spyOn(mockRepository, 'getWorkflowVersions').mockReturnValue([versionWithOps]);
const result = await service.getVersionHistory('workflow-1', 10);
expect(result[0].operationCount).toBe(2);
});
it('should include fixTypes when present', async () => {
const versionWithFixes: WorkflowVersion = {
...createMockVersion(1),
fixTypes: ['expression-format', 'typeversion-correction']
};
vi.spyOn(mockRepository, 'getWorkflowVersions').mockReturnValue([versionWithFixes]);
const result = await service.getVersionHistory('workflow-1', 10);
expect(result[0].fixTypesApplied).toEqual(['expression-format', 'typeversion-correction']);
});
it('should respect the limit parameter', async () => {
vi.spyOn(mockRepository, 'getWorkflowVersions').mockReturnValue([]);
await service.getVersionHistory('workflow-1', 5);
expect(mockRepository.getWorkflowVersions).toHaveBeenCalledWith('workflow-1', 5);
});
});
describe('getVersion', () => {
it('should return the requested version', async () => {
const version = createMockVersion(1);
vi.spyOn(mockRepository, 'getWorkflowVersion').mockReturnValue(version);
const result = await service.getVersion(1);
expect(result).toEqual(version);
});
it('should return null if version does not exist', async () => {
vi.spyOn(mockRepository, 'getWorkflowVersion').mockReturnValue(null);
const result = await service.getVersion(999);
expect(result).toBeNull();
});
});
describe('restoreVersion', () => {
it('should fail if API client is not configured', async () => {
const serviceWithoutApi = new WorkflowVersioningService(mockRepository);
const result = await serviceWithoutApi.restoreVersion('workflow-1', 1);
expect(result.success).toBe(false);
expect(result.message).toContain('API client not configured');
expect(result.backupCreated).toBe(false);
});
it('should fail if version does not exist', async () => {
vi.spyOn(mockRepository, 'getWorkflowVersion').mockReturnValue(null);
const result = await service.restoreVersion('workflow-1', 999);
expect(result.success).toBe(false);
expect(result.message).toContain('Version 999 not found');
expect(result.backupCreated).toBe(false);
});
it('should restore latest version when no versionId provided', async () => {
const version = createMockVersion(3);
vi.spyOn(mockRepository, 'getLatestWorkflowVersion').mockReturnValue(version);
vi.spyOn(mockRepository, 'getWorkflowVersions').mockReturnValue([]);
vi.spyOn(mockRepository, 'createWorkflowVersion').mockReturnValue(4);
vi.spyOn(mockRepository, 'pruneWorkflowVersions').mockReturnValue(0);
vi.spyOn(mockApiClient, 'getWorkflow').mockResolvedValue(createMockWorkflow('workflow-1', 'Current'));
vi.spyOn(mockApiClient, 'updateWorkflow').mockResolvedValue(createMockWorkflow('workflow-1', 'Restored'));
const result = await service.restoreVersion('workflow-1', undefined, false);
expect(mockRepository.getLatestWorkflowVersion).toHaveBeenCalledWith('workflow-1');
expect(result.success).toBe(true);
});
it('should fail if no backup versions exist and no versionId provided', async () => {
vi.spyOn(mockRepository, 'getLatestWorkflowVersion').mockReturnValue(null);
const result = await service.restoreVersion('workflow-1', undefined);
expect(result.success).toBe(false);
expect(result.message).toContain('No backup versions found');
});
it('should validate version before restore when validateBefore is true', async () => {
const version = createMockVersion(1);
vi.spyOn(mockRepository, 'getWorkflowVersion').mockReturnValue(version);
const mockValidator = {
validateWorkflow: vi.fn().mockResolvedValue({
errors: [{ message: 'Validation error' }]
})
};
vi.spyOn(WorkflowValidator.prototype, 'validateWorkflow').mockImplementation(
mockValidator.validateWorkflow
);
const result = await service.restoreVersion('workflow-1', 1, true);
expect(result.success).toBe(false);
expect(result.message).toContain('has validation errors');
expect(result.validationErrors).toEqual(['Validation error']);
expect(result.backupCreated).toBe(false);
});
it('should skip validation when validateBefore is false', async () => {
const version = createMockVersion(1);
vi.spyOn(mockRepository, 'getWorkflowVersion').mockReturnValue(version);
vi.spyOn(mockRepository, 'getWorkflowVersions').mockReturnValue([]);
vi.spyOn(mockRepository, 'createWorkflowVersion').mockReturnValue(2);
vi.spyOn(mockRepository, 'pruneWorkflowVersions').mockReturnValue(0);
vi.spyOn(mockApiClient, 'getWorkflow').mockResolvedValue(createMockWorkflow('workflow-1', 'Current'));
vi.spyOn(mockApiClient, 'updateWorkflow').mockResolvedValue(createMockWorkflow('workflow-1', 'Restored'));
const mockValidator = vi.fn();
vi.spyOn(WorkflowValidator.prototype, 'validateWorkflow').mockImplementation(mockValidator);
await service.restoreVersion('workflow-1', 1, false);
expect(mockValidator).not.toHaveBeenCalled();
});
it('should create backup before restoring', async () => {
const versionToRestore = createMockVersion(1);
const currentWorkflow = createMockWorkflow('workflow-1', 'Current Workflow');
vi.spyOn(mockRepository, 'getWorkflowVersion').mockReturnValue(versionToRestore);
vi.spyOn(mockRepository, 'getWorkflowVersions').mockReturnValue([createMockVersion(2)]);
vi.spyOn(mockRepository, 'createWorkflowVersion').mockReturnValue(3);
vi.spyOn(mockRepository, 'pruneWorkflowVersions').mockReturnValue(0);
vi.spyOn(mockApiClient, 'getWorkflow').mockResolvedValue(currentWorkflow);
vi.spyOn(mockApiClient, 'updateWorkflow').mockResolvedValue(createMockWorkflow('workflow-1', 'Restored'));
const result = await service.restoreVersion('workflow-1', 1, false);
expect(mockApiClient.getWorkflow).toHaveBeenCalledWith('workflow-1');
expect(mockRepository.createWorkflowVersion).toHaveBeenCalledWith(
expect.objectContaining({
workflowSnapshot: currentWorkflow,
metadata: expect.objectContaining({
reason: 'Backup before rollback',
restoringToVersion: 1
})
})
);
expect(result.backupCreated).toBe(true);
expect(result.backupVersionId).toBe(3);
});
it('should fail if backup creation fails', async () => {
const version = createMockVersion(1);
vi.spyOn(mockRepository, 'getWorkflowVersion').mockReturnValue(version);
vi.spyOn(mockApiClient, 'getWorkflow').mockRejectedValue(new Error('Backup failed'));
const result = await service.restoreVersion('workflow-1', 1, false);
expect(result.success).toBe(false);
expect(result.message).toContain('Failed to create backup before restore');
expect(result.backupCreated).toBe(false);
});
it('should successfully restore workflow', async () => {
const versionToRestore = createMockVersion(1);
vi.spyOn(mockRepository, 'getWorkflowVersion').mockReturnValue(versionToRestore);
vi.spyOn(mockRepository, 'getWorkflowVersions').mockReturnValue([createMockVersion(2)]);
vi.spyOn(mockRepository, 'createWorkflowVersion').mockReturnValue(3);
vi.spyOn(mockRepository, 'pruneWorkflowVersions').mockReturnValue(0);
vi.spyOn(mockApiClient, 'getWorkflow').mockResolvedValue(createMockWorkflow('workflow-1', 'Current'));
vi.spyOn(mockApiClient, 'updateWorkflow').mockResolvedValue(createMockWorkflow('workflow-1', 'Restored'));
const result = await service.restoreVersion('workflow-1', 1, false);
expect(mockApiClient.updateWorkflow).toHaveBeenCalledWith('workflow-1', versionToRestore.workflowSnapshot);
expect(result.success).toBe(true);
expect(result.message).toContain('Successfully restored workflow to version 1');
expect(result.fromVersion).toBe(3);
expect(result.toVersionId).toBe(1);
});
it('should handle restore API failures', async () => {
const version = createMockVersion(1);
vi.spyOn(mockRepository, 'getWorkflowVersion').mockReturnValue(version);
vi.spyOn(mockRepository, 'getWorkflowVersions').mockReturnValue([]);
vi.spyOn(mockRepository, 'createWorkflowVersion').mockReturnValue(2);
vi.spyOn(mockRepository, 'pruneWorkflowVersions').mockReturnValue(0);
vi.spyOn(mockApiClient, 'getWorkflow').mockResolvedValue(createMockWorkflow('workflow-1', 'Current'));
vi.spyOn(mockApiClient, 'updateWorkflow').mockRejectedValue(new Error('API Error'));
const result = await service.restoreVersion('workflow-1', 1, false);
expect(result.success).toBe(false);
expect(result.message).toContain('Failed to restore workflow');
expect(result.backupCreated).toBe(true);
expect(result.backupVersionId).toBe(2);
});
});
describe('deleteVersion', () => {
it('should delete a specific version', async () => {
const version = createMockVersion(1);
vi.spyOn(mockRepository, 'getWorkflowVersion').mockReturnValue(version);
vi.spyOn(mockRepository, 'deleteWorkflowVersion').mockReturnValue(undefined);
const result = await service.deleteVersion(1);
expect(mockRepository.deleteWorkflowVersion).toHaveBeenCalledWith(1);
expect(result.success).toBe(true);
expect(result.message).toContain('Deleted version 1');
});
it('should fail if version does not exist', async () => {
vi.spyOn(mockRepository, 'getWorkflowVersion').mockReturnValue(null);
const result = await service.deleteVersion(999);
expect(result.success).toBe(false);
expect(result.message).toContain('Version 999 not found');
});
});
describe('deleteAllVersions', () => {
it('should delete all versions for a workflow', async () => {
vi.spyOn(mockRepository, 'getWorkflowVersionCount').mockReturnValue(5);
vi.spyOn(mockRepository, 'deleteWorkflowVersionsByWorkflowId').mockReturnValue(5);
const result = await service.deleteAllVersions('workflow-1');
expect(result.deleted).toBe(5);
expect(result.message).toContain('Deleted 5 version(s)');
});
it('should return zero if no versions exist', async () => {
vi.spyOn(mockRepository, 'getWorkflowVersionCount').mockReturnValue(0);
const result = await service.deleteAllVersions('workflow-1');
expect(result.deleted).toBe(0);
expect(result.message).toContain('No versions found');
});
});
describe('pruneVersions', () => {
it('should prune versions and return counts', async () => {
vi.spyOn(mockRepository, 'pruneWorkflowVersions').mockReturnValue(3);
vi.spyOn(mockRepository, 'getWorkflowVersionCount').mockReturnValue(10);
const result = await service.pruneVersions('workflow-1', 10);
expect(result.pruned).toBe(3);
expect(result.remaining).toBe(10);
});
it('should use custom maxVersions parameter', async () => {
vi.spyOn(mockRepository, 'pruneWorkflowVersions').mockReturnValue(0);
vi.spyOn(mockRepository, 'getWorkflowVersionCount').mockReturnValue(5);
await service.pruneVersions('workflow-1', 5);
expect(mockRepository.pruneWorkflowVersions).toHaveBeenCalledWith('workflow-1', 5);
});
});
describe('truncateAllVersions', () => {
it('should refuse to truncate without confirmation', async () => {
const result = await service.truncateAllVersions(false);
expect(result.deleted).toBe(0);
expect(result.message).toContain('not confirmed');
});
it('should truncate all versions when confirmed', async () => {
vi.spyOn(mockRepository, 'truncateWorkflowVersions').mockReturnValue(50);
const result = await service.truncateAllVersions(true);
expect(result.deleted).toBe(50);
expect(result.message).toContain('Truncated workflow_versions table');
});
});
describe('getStorageStats', () => {
it('should return formatted storage statistics', async () => {
const mockStats = {
totalVersions: 10,
totalSize: 1024000,
byWorkflow: [
{
workflowId: 'workflow-1',
workflowName: 'Test Workflow',
versionCount: 5,
totalSize: 512000,
lastBackup: '2025-01-01T00:00:00.000Z'
}
]
};
vi.spyOn(mockRepository, 'getVersionStorageStats').mockReturnValue(mockStats);
const result = await service.getStorageStats();
expect(result.totalVersions).toBe(10);
expect(result.totalSizeFormatted).toContain('KB');
expect(result.byWorkflow).toHaveLength(1);
expect(result.byWorkflow[0].totalSizeFormatted).toContain('KB');
});
it('should format bytes correctly', async () => {
const mockStats = {
totalVersions: 1,
totalSize: 0,
byWorkflow: []
};
vi.spyOn(mockRepository, 'getVersionStorageStats').mockReturnValue(mockStats);
const result = await service.getStorageStats();
expect(result.totalSizeFormatted).toBe('0 Bytes');
});
});
describe('compareVersions', () => {
it('should detect added nodes', async () => {
const v1 = createMockVersion(1);
v1.workflowSnapshot.nodes = [{ id: 'node-1', name: 'Node 1', type: 'test', typeVersion: 1, position: [0, 0], parameters: {} }];
const v2 = createMockVersion(2);
v2.workflowSnapshot.nodes = [
{ id: 'node-1', name: 'Node 1', type: 'test', typeVersion: 1, position: [0, 0], parameters: {} },
{ id: 'node-2', name: 'Node 2', type: 'test', typeVersion: 1, position: [100, 0], parameters: {} }
];
vi.spyOn(mockRepository, 'getWorkflowVersion')
.mockReturnValueOnce(v1)
.mockReturnValueOnce(v2);
const result = await service.compareVersions(1, 2);
expect(result.addedNodes).toEqual(['node-2']);
expect(result.removedNodes).toEqual([]);
expect(result.modifiedNodes).toEqual([]);
});
it('should detect removed nodes', async () => {
const v1 = createMockVersion(1);
v1.workflowSnapshot.nodes = [
{ id: 'node-1', name: 'Node 1', type: 'test', typeVersion: 1, position: [0, 0], parameters: {} },
{ id: 'node-2', name: 'Node 2', type: 'test', typeVersion: 1, position: [100, 0], parameters: {} }
];
const v2 = createMockVersion(2);
v2.workflowSnapshot.nodes = [{ id: 'node-1', name: 'Node 1', type: 'test', typeVersion: 1, position: [0, 0], parameters: {} }];
vi.spyOn(mockRepository, 'getWorkflowVersion')
.mockReturnValueOnce(v1)
.mockReturnValueOnce(v2);
const result = await service.compareVersions(1, 2);
expect(result.removedNodes).toEqual(['node-2']);
expect(result.addedNodes).toEqual([]);
});
it('should detect modified nodes', async () => {
const v1 = createMockVersion(1);
v1.workflowSnapshot.nodes = [{ id: 'node-1', name: 'Node 1', type: 'test', typeVersion: 1, position: [0, 0], parameters: {} }];
const v2 = createMockVersion(2);
v2.workflowSnapshot.nodes = [{ id: 'node-1', name: 'Node 1', type: 'test', typeVersion: 2, position: [0, 0], parameters: {} }];
vi.spyOn(mockRepository, 'getWorkflowVersion')
.mockReturnValueOnce(v1)
.mockReturnValueOnce(v2);
const result = await service.compareVersions(1, 2);
expect(result.modifiedNodes).toEqual(['node-1']);
});
it('should detect connection changes', async () => {
const v1 = createMockVersion(1);
v1.workflowSnapshot.connections = { 'node-1': { main: [[{ node: 'node-2', type: 'main', index: 0 }]] } };
const v2 = createMockVersion(2);
v2.workflowSnapshot.connections = {};
vi.spyOn(mockRepository, 'getWorkflowVersion')
.mockReturnValueOnce(v1)
.mockReturnValueOnce(v2);
const result = await service.compareVersions(1, 2);
expect(result.connectionChanges).toBe(1);
});
it('should detect settings changes', async () => {
const v1 = createMockVersion(1);
v1.workflowSnapshot.settings = { executionOrder: 'v0' };
const v2 = createMockVersion(2);
v2.workflowSnapshot.settings = { executionOrder: 'v1' };
vi.spyOn(mockRepository, 'getWorkflowVersion')
.mockReturnValueOnce(v1)
.mockReturnValueOnce(v2);
const result = await service.compareVersions(1, 2);
expect(result.settingChanges).toHaveProperty('executionOrder');
expect(result.settingChanges.executionOrder.before).toBe('v0');
expect(result.settingChanges.executionOrder.after).toBe('v1');
});
it('should throw error if version not found', async () => {
vi.spyOn(mockRepository, 'getWorkflowVersion').mockReturnValue(null);
await expect(service.compareVersions(1, 2)).rejects.toThrow('One or both versions not found');
});
});
describe('formatBytes', () => {
it('should format bytes to human-readable string', () => {
// Access private method through any cast
const formatBytes = (service as any).formatBytes.bind(service);
expect(formatBytes(0)).toBe('0 Bytes');
expect(formatBytes(500)).toBe('500 Bytes');
expect(formatBytes(1024)).toBe('1 KB');
expect(formatBytes(1048576)).toBe('1 MB');
expect(formatBytes(1073741824)).toBe('1 GB');
});
});
describe('diffObjects', () => {
it('should detect object differences', () => {
const diffObjects = (service as any).diffObjects.bind(service);
const obj1 = { a: 1, b: 2 };
const obj2 = { a: 1, b: 3, c: 4 };
const diff = diffObjects(obj1, obj2);
expect(diff).toHaveProperty('b');
expect(diff.b).toEqual({ before: 2, after: 3 });
expect(diff).toHaveProperty('c');
expect(diff.c).toEqual({ before: undefined, after: 4 });
});
it('should return empty object when no differences', () => {
const diffObjects = (service as any).diffObjects.bind(service);
const obj1 = { a: 1, b: 2 };
const obj2 = { a: 1, b: 2 };
const diff = diffObjects(obj1, obj2);
expect(Object.keys(diff)).toHaveLength(0);
});
});
});