breaking-change-detector.test.ts•25 kB
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { BreakingChangeDetector, type DetectedChange, type VersionUpgradeAnalysis } from '@/services/breaking-change-detector';
import { NodeRepository } from '@/database/node-repository';
import * as BreakingChangesRegistry from '@/services/breaking-changes-registry';
vi.mock('@/database/node-repository');
vi.mock('@/services/breaking-changes-registry');
describe('BreakingChangeDetector', () => {
let detector: BreakingChangeDetector;
let mockRepository: NodeRepository;
const createMockVersionData = (version: string, properties: any[] = []) => ({
nodeType: 'nodes-base.httpRequest',
version,
packageName: 'n8n-nodes-base',
displayName: 'HTTP Request',
isCurrentMax: false,
propertiesSchema: properties,
breakingChanges: [],
deprecatedProperties: [],
addedProperties: []
});
const createMockProperty = (name: string, type: string = 'string', required = false) => ({
name,
displayName: name,
type,
required,
default: null
});
beforeEach(() => {
vi.clearAllMocks();
mockRepository = new NodeRepository({} as any);
detector = new BreakingChangeDetector(mockRepository);
});
describe('analyzeVersionUpgrade', () => {
it('should combine registry and dynamic changes', async () => {
const registryChange: BreakingChangesRegistry.BreakingChange = {
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'registryProp',
changeType: 'removed',
isBreaking: true,
migrationHint: 'From registry',
autoMigratable: true,
severity: 'HIGH',
migrationStrategy: { type: 'remove_property' }
};
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue([registryChange]);
const v1 = createMockVersionData('1.0', [createMockProperty('dynamicProp')]);
const v2 = createMockVersionData('2.0', []);
vi.spyOn(mockRepository, 'getNodeVersion')
.mockReturnValueOnce(v1)
.mockReturnValueOnce(v2);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
expect(result.changes.length).toBeGreaterThan(0);
expect(result.changes.some(c => c.source === 'registry')).toBe(true);
expect(result.changes.some(c => c.source === 'dynamic')).toBe(true);
});
it('should detect breaking changes', async () => {
const breakingChange: BreakingChangesRegistry.BreakingChange = {
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'criticalProp',
changeType: 'removed',
isBreaking: true,
migrationHint: 'This is breaking',
autoMigratable: false,
severity: 'HIGH',
migrationStrategy: undefined
};
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue([breakingChange]);
vi.spyOn(mockRepository, 'getNodeVersion').mockReturnValue(null);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
expect(result.hasBreakingChanges).toBe(true);
});
it('should calculate auto-migratable and manual counts', async () => {
const changes: BreakingChangesRegistry.BreakingChange[] = [
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'autoProp',
changeType: 'added',
isBreaking: false,
migrationHint: 'Auto',
autoMigratable: true,
severity: 'LOW',
migrationStrategy: { type: 'add_property', defaultValue: null }
},
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'manualProp',
changeType: 'requirement_changed',
isBreaking: true,
migrationHint: 'Manual',
autoMigratable: false,
severity: 'HIGH',
migrationStrategy: undefined
}
];
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue(changes);
vi.spyOn(mockRepository, 'getNodeVersion').mockReturnValue(null);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
expect(result.autoMigratableCount).toBe(1);
expect(result.manualRequiredCount).toBe(1);
});
it('should determine overall severity', async () => {
const highSeverityChange: BreakingChangesRegistry.BreakingChange = {
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'criticalProp',
changeType: 'removed',
isBreaking: true,
migrationHint: 'Critical',
autoMigratable: false,
severity: 'HIGH',
migrationStrategy: undefined
};
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue([highSeverityChange]);
vi.spyOn(mockRepository, 'getNodeVersion').mockReturnValue(null);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
expect(result.overallSeverity).toBe('HIGH');
});
it('should generate recommendations', async () => {
const changes: BreakingChangesRegistry.BreakingChange[] = [
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'prop1',
changeType: 'removed',
isBreaking: true,
migrationHint: 'Remove this',
autoMigratable: true,
severity: 'MEDIUM',
migrationStrategy: { type: 'remove_property' }
},
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'prop2',
changeType: 'requirement_changed',
isBreaking: true,
migrationHint: 'Manual work needed',
autoMigratable: false,
severity: 'HIGH',
migrationStrategy: undefined
}
];
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue(changes);
vi.spyOn(mockRepository, 'getNodeVersion').mockReturnValue(null);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
expect(result.recommendations.length).toBeGreaterThan(0);
expect(result.recommendations.some(r => r.includes('breaking change'))).toBe(true);
expect(result.recommendations.some(r => r.includes('automatically migrated'))).toBe(true);
expect(result.recommendations.some(r => r.includes('manual intervention'))).toBe(true);
});
});
describe('dynamic change detection', () => {
it('should detect added properties', async () => {
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue([]);
const v1 = createMockVersionData('1.0', []);
const v2 = createMockVersionData('2.0', [createMockProperty('newProp')]);
vi.spyOn(mockRepository, 'getNodeVersion')
.mockReturnValueOnce(v1)
.mockReturnValueOnce(v2);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
const addedChange = result.changes.find(c => c.changeType === 'added');
expect(addedChange).toBeDefined();
expect(addedChange?.propertyName).toBe('newProp');
expect(addedChange?.source).toBe('dynamic');
});
it('should mark required added properties as breaking', async () => {
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue([]);
const v1 = createMockVersionData('1.0', []);
const v2 = createMockVersionData('2.0', [createMockProperty('requiredProp', 'string', true)]);
vi.spyOn(mockRepository, 'getNodeVersion')
.mockReturnValueOnce(v1)
.mockReturnValueOnce(v2);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
const addedChange = result.changes.find(c => c.changeType === 'added');
expect(addedChange?.isBreaking).toBe(true);
expect(addedChange?.severity).toBe('HIGH');
expect(addedChange?.autoMigratable).toBe(false);
});
it('should mark optional added properties as non-breaking', async () => {
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue([]);
const v1 = createMockVersionData('1.0', []);
const v2 = createMockVersionData('2.0', [createMockProperty('optionalProp', 'string', false)]);
vi.spyOn(mockRepository, 'getNodeVersion')
.mockReturnValueOnce(v1)
.mockReturnValueOnce(v2);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
const addedChange = result.changes.find(c => c.changeType === 'added');
expect(addedChange?.isBreaking).toBe(false);
expect(addedChange?.severity).toBe('LOW');
expect(addedChange?.autoMigratable).toBe(true);
});
it('should detect removed properties', async () => {
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue([]);
const v1 = createMockVersionData('1.0', [createMockProperty('oldProp')]);
const v2 = createMockVersionData('2.0', []);
vi.spyOn(mockRepository, 'getNodeVersion')
.mockReturnValueOnce(v1)
.mockReturnValueOnce(v2);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
const removedChange = result.changes.find(c => c.changeType === 'removed');
expect(removedChange).toBeDefined();
expect(removedChange?.propertyName).toBe('oldProp');
expect(removedChange?.isBreaking).toBe(true);
expect(removedChange?.autoMigratable).toBe(true);
});
it('should detect requirement changes', async () => {
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue([]);
const v1 = createMockVersionData('1.0', [createMockProperty('prop', 'string', false)]);
const v2 = createMockVersionData('2.0', [createMockProperty('prop', 'string', true)]);
vi.spyOn(mockRepository, 'getNodeVersion')
.mockReturnValueOnce(v1)
.mockReturnValueOnce(v2);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
const requirementChange = result.changes.find(c => c.changeType === 'requirement_changed');
expect(requirementChange).toBeDefined();
expect(requirementChange?.isBreaking).toBe(true);
expect(requirementChange?.oldValue).toBe('optional');
expect(requirementChange?.newValue).toBe('required');
});
it('should detect when property becomes optional', async () => {
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue([]);
const v1 = createMockVersionData('1.0', [createMockProperty('prop', 'string', true)]);
const v2 = createMockVersionData('2.0', [createMockProperty('prop', 'string', false)]);
vi.spyOn(mockRepository, 'getNodeVersion')
.mockReturnValueOnce(v1)
.mockReturnValueOnce(v2);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
const requirementChange = result.changes.find(c => c.changeType === 'requirement_changed');
expect(requirementChange).toBeDefined();
expect(requirementChange?.isBreaking).toBe(false);
expect(requirementChange?.severity).toBe('LOW');
});
it('should handle missing version data gracefully', async () => {
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue([]);
vi.spyOn(mockRepository, 'getNodeVersion').mockReturnValue(null);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
expect(result.changes.filter(c => c.source === 'dynamic')).toHaveLength(0);
});
it('should handle missing properties schema', async () => {
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue([]);
const v1 = { ...createMockVersionData('1.0'), propertiesSchema: null };
const v2 = { ...createMockVersionData('2.0'), propertiesSchema: null };
vi.spyOn(mockRepository, 'getNodeVersion')
.mockReturnValueOnce(v1 as any)
.mockReturnValueOnce(v2 as any);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
expect(result.changes.filter(c => c.source === 'dynamic')).toHaveLength(0);
});
});
describe('change merging and deduplication', () => {
it('should prioritize registry changes over dynamic', async () => {
const registryChange: BreakingChangesRegistry.BreakingChange = {
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'sharedProp',
changeType: 'removed',
isBreaking: true,
migrationHint: 'From registry',
autoMigratable: true,
severity: 'HIGH',
migrationStrategy: { type: 'remove_property' }
};
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue([registryChange]);
const v1 = createMockVersionData('1.0', [createMockProperty('sharedProp')]);
const v2 = createMockVersionData('2.0', []);
vi.spyOn(mockRepository, 'getNodeVersion')
.mockReturnValueOnce(v1)
.mockReturnValueOnce(v2);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
const sharedChanges = result.changes.filter(c => c.propertyName === 'sharedProp');
expect(sharedChanges).toHaveLength(1);
expect(sharedChanges[0].source).toBe('registry');
});
it('should sort changes by severity', async () => {
const changes: BreakingChangesRegistry.BreakingChange[] = [
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'lowProp',
changeType: 'added',
isBreaking: false,
migrationHint: 'Low',
autoMigratable: true,
severity: 'LOW',
migrationStrategy: { type: 'add_property', defaultValue: null }
},
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'highProp',
changeType: 'removed',
isBreaking: true,
migrationHint: 'High',
autoMigratable: false,
severity: 'HIGH',
migrationStrategy: undefined
},
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'medProp',
changeType: 'renamed',
isBreaking: true,
migrationHint: 'Medium',
autoMigratable: true,
severity: 'MEDIUM',
migrationStrategy: { type: 'rename_property', sourceProperty: 'old', targetProperty: 'new' }
}
];
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue(changes);
vi.spyOn(mockRepository, 'getNodeVersion').mockReturnValue(null);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
expect(result.changes[0].severity).toBe('HIGH');
expect(result.changes[result.changes.length - 1].severity).toBe('LOW');
});
});
describe('hasBreakingChanges', () => {
it('should return true when breaking changes exist', () => {
const breakingChange: BreakingChangesRegistry.BreakingChange = {
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'prop',
changeType: 'removed',
isBreaking: true,
migrationHint: 'Breaking',
autoMigratable: false,
severity: 'HIGH',
migrationStrategy: undefined
};
vi.spyOn(BreakingChangesRegistry, 'getBreakingChangesForNode').mockReturnValue([breakingChange]);
const result = detector.hasBreakingChanges('nodes-base.httpRequest', '1.0', '2.0');
expect(result).toBe(true);
});
it('should return false when no breaking changes', () => {
vi.spyOn(BreakingChangesRegistry, 'getBreakingChangesForNode').mockReturnValue([]);
const result = detector.hasBreakingChanges('nodes-base.httpRequest', '1.0', '2.0');
expect(result).toBe(false);
});
});
describe('getChangedProperties', () => {
it('should return list of changed property names', () => {
const changes: BreakingChangesRegistry.BreakingChange[] = [
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'prop1',
changeType: 'added',
isBreaking: false,
migrationHint: '',
autoMigratable: true,
severity: 'LOW',
migrationStrategy: undefined
},
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'prop2',
changeType: 'removed',
isBreaking: true,
migrationHint: '',
autoMigratable: true,
severity: 'MEDIUM',
migrationStrategy: undefined
}
];
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue(changes);
const result = detector.getChangedProperties('nodes-base.httpRequest', '1.0', '2.0');
expect(result).toEqual(['prop1', 'prop2']);
});
it('should return empty array when no changes', () => {
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue([]);
const result = detector.getChangedProperties('nodes-base.httpRequest', '1.0', '2.0');
expect(result).toEqual([]);
});
});
describe('recommendations generation', () => {
it('should recommend safe upgrade when no breaking changes', async () => {
const changes: BreakingChangesRegistry.BreakingChange[] = [
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'prop',
changeType: 'added',
isBreaking: false,
migrationHint: 'Safe',
autoMigratable: true,
severity: 'LOW',
migrationStrategy: { type: 'add_property', defaultValue: null }
}
];
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue(changes);
vi.spyOn(mockRepository, 'getNodeVersion').mockReturnValue(null);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
expect(result.recommendations.some(r => r.includes('No breaking changes'))).toBe(true);
expect(result.recommendations.some(r => r.includes('safe'))).toBe(true);
});
it('should warn about breaking changes', async () => {
const changes: BreakingChangesRegistry.BreakingChange[] = [
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'prop',
changeType: 'removed',
isBreaking: true,
migrationHint: 'Breaking',
autoMigratable: false,
severity: 'HIGH',
migrationStrategy: undefined
}
];
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue(changes);
vi.spyOn(mockRepository, 'getNodeVersion').mockReturnValue(null);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
expect(result.recommendations.some(r => r.includes('breaking change'))).toBe(true);
});
it('should list manual changes required', async () => {
const changes: BreakingChangesRegistry.BreakingChange[] = [
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'manualProp',
changeType: 'requirement_changed',
isBreaking: true,
migrationHint: 'Manually configure this',
autoMigratable: false,
severity: 'HIGH',
migrationStrategy: undefined
}
];
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue(changes);
vi.spyOn(mockRepository, 'getNodeVersion').mockReturnValue(null);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
expect(result.recommendations.some(r => r.includes('manual intervention'))).toBe(true);
expect(result.recommendations.some(r => r.includes('manualProp'))).toBe(true);
});
});
describe('nested properties', () => {
it('should flatten nested properties for comparison', async () => {
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue([]);
const nestedProp = {
name: 'parent',
displayName: 'Parent',
type: 'options',
options: [
createMockProperty('child1'),
createMockProperty('child2')
]
};
const v1 = createMockVersionData('1.0', [nestedProp]);
const v2 = createMockVersionData('2.0', []);
vi.spyOn(mockRepository, 'getNodeVersion')
.mockReturnValueOnce(v1)
.mockReturnValueOnce(v2);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
// Should detect removal of parent and nested properties
expect(result.changes.some(c => c.propertyName.includes('parent'))).toBe(true);
});
});
describe('overall severity calculation', () => {
it('should return HIGH when any change is HIGH severity', async () => {
const changes: BreakingChangesRegistry.BreakingChange[] = [
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'lowProp',
changeType: 'added',
isBreaking: false,
migrationHint: '',
autoMigratable: true,
severity: 'LOW',
migrationStrategy: undefined
},
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'highProp',
changeType: 'removed',
isBreaking: true,
migrationHint: '',
autoMigratable: false,
severity: 'HIGH',
migrationStrategy: undefined
}
];
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue(changes);
vi.spyOn(mockRepository, 'getNodeVersion').mockReturnValue(null);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
expect(result.overallSeverity).toBe('HIGH');
});
it('should return MEDIUM when no HIGH but has MEDIUM', async () => {
const changes: BreakingChangesRegistry.BreakingChange[] = [
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'lowProp',
changeType: 'added',
isBreaking: false,
migrationHint: '',
autoMigratable: true,
severity: 'LOW',
migrationStrategy: undefined
},
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'medProp',
changeType: 'renamed',
isBreaking: true,
migrationHint: '',
autoMigratable: true,
severity: 'MEDIUM',
migrationStrategy: undefined
}
];
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue(changes);
vi.spyOn(mockRepository, 'getNodeVersion').mockReturnValue(null);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
expect(result.overallSeverity).toBe('MEDIUM');
});
it('should return LOW when all changes are LOW severity', async () => {
const changes: BreakingChangesRegistry.BreakingChange[] = [
{
nodeType: 'nodes-base.httpRequest',
fromVersion: '1.0',
toVersion: '2.0',
propertyName: 'prop',
changeType: 'added',
isBreaking: false,
migrationHint: '',
autoMigratable: true,
severity: 'LOW',
migrationStrategy: undefined
}
];
vi.spyOn(BreakingChangesRegistry, 'getAllChangesForNode').mockReturnValue(changes);
vi.spyOn(mockRepository, 'getNodeVersion').mockReturnValue(null);
const result = await detector.analyzeVersionUpgrade('nodes-base.httpRequest', '1.0', '2.0');
expect(result.overallSeverity).toBe('LOW');
});
});
});