node-repository-core.test.tsβ’12.5 kB
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { NodeRepository } from '../../../src/database/node-repository';
import { DatabaseAdapter, PreparedStatement, RunResult } from '../../../src/database/database-adapter';
import { ParsedNode } from '../../../src/parsers/node-parser';
// Create a complete mock for DatabaseAdapter
class MockDatabaseAdapter implements DatabaseAdapter {
private statements = new Map<string, MockPreparedStatement>();
private mockData = new Map<string, any>();
prepare = vi.fn((sql: string) => {
if (!this.statements.has(sql)) {
this.statements.set(sql, new MockPreparedStatement(sql, this.mockData));
}
return this.statements.get(sql)!;
});
exec = vi.fn();
close = vi.fn();
pragma = vi.fn();
transaction = vi.fn((fn: () => any) => fn());
checkFTS5Support = vi.fn(() => true);
inTransaction = false;
// Test helper to set mock data
_setMockData(key: string, value: any) {
this.mockData.set(key, value);
}
// Test helper to get statement by SQL
_getStatement(sql: string) {
return this.statements.get(sql);
}
}
class MockPreparedStatement implements PreparedStatement {
run = vi.fn((...params: any[]): RunResult => ({ changes: 1, lastInsertRowid: 1 }));
get = vi.fn();
all = vi.fn(() => []);
iterate = vi.fn();
pluck = vi.fn(() => this);
expand = vi.fn(() => this);
raw = vi.fn(() => this);
columns = vi.fn(() => []);
bind = vi.fn(() => this);
constructor(private sql: string, private mockData: Map<string, any>) {
// Configure get() based on SQL pattern
if (sql.includes('SELECT * FROM nodes WHERE node_type = ?')) {
this.get = vi.fn((nodeType: string) => this.mockData.get(`node:${nodeType}`));
}
// Configure all() for getAITools
if (sql.includes('WHERE is_ai_tool = 1')) {
this.all = vi.fn(() => this.mockData.get('ai_tools') || []);
}
}
}
describe('NodeRepository - Core Functionality', () => {
let repository: NodeRepository;
let mockAdapter: MockDatabaseAdapter;
beforeEach(() => {
mockAdapter = new MockDatabaseAdapter();
repository = new NodeRepository(mockAdapter);
});
describe('saveNode', () => {
it('should save a node with proper JSON serialization', () => {
const parsedNode: ParsedNode = {
nodeType: 'nodes-base.httpRequest',
displayName: 'HTTP Request',
description: 'Makes HTTP requests',
category: 'transform',
style: 'declarative',
packageName: 'n8n-nodes-base',
properties: [{ name: 'url', type: 'string' }],
operations: [{ name: 'execute', displayName: 'Execute' }],
credentials: [{ name: 'httpBasicAuth' }],
isAITool: false,
isTrigger: false,
isWebhook: false,
isVersioned: true,
version: '1.0',
documentation: 'HTTP Request documentation',
outputs: undefined,
outputNames: undefined
};
repository.saveNode(parsedNode);
// Verify prepare was called with correct SQL
expect(mockAdapter.prepare).toHaveBeenCalledWith(expect.stringContaining('INSERT OR REPLACE INTO nodes'));
// Get the prepared statement and verify run was called
const stmt = mockAdapter._getStatement(mockAdapter.prepare.mock.lastCall?.[0] || '');
expect(stmt?.run).toHaveBeenCalledWith(
'nodes-base.httpRequest',
'n8n-nodes-base',
'HTTP Request',
'Makes HTTP requests',
'transform',
'declarative',
0, // isAITool
0, // isTrigger
0, // isWebhook
1, // isVersioned
'1.0',
'HTTP Request documentation',
JSON.stringify([{ name: 'url', type: 'string' }], null, 2),
JSON.stringify([{ name: 'execute', displayName: 'Execute' }], null, 2),
JSON.stringify([{ name: 'httpBasicAuth' }], null, 2),
null, // outputs
null // outputNames
);
});
it('should handle nodes without optional fields', () => {
const minimalNode: ParsedNode = {
nodeType: 'nodes-base.simple',
displayName: 'Simple Node',
category: 'core',
style: 'programmatic',
packageName: 'n8n-nodes-base',
properties: [],
operations: [],
credentials: [],
isAITool: true,
isTrigger: true,
isWebhook: true,
isVersioned: false,
outputs: undefined,
outputNames: undefined
};
repository.saveNode(minimalNode);
const stmt = mockAdapter._getStatement(mockAdapter.prepare.mock.lastCall?.[0] || '');
const runCall = stmt?.run.mock.lastCall;
expect(runCall?.[2]).toBe('Simple Node'); // displayName
expect(runCall?.[3]).toBeUndefined(); // description
expect(runCall?.[10]).toBeUndefined(); // version
expect(runCall?.[11]).toBeNull(); // documentation
});
});
describe('getNode', () => {
it('should retrieve and deserialize a node correctly', () => {
const mockRow = {
node_type: 'nodes-base.httpRequest',
display_name: 'HTTP Request',
description: 'Makes HTTP requests',
category: 'transform',
development_style: 'declarative',
package_name: 'n8n-nodes-base',
is_ai_tool: 0,
is_trigger: 0,
is_webhook: 0,
is_versioned: 1,
version: '1.0',
properties_schema: JSON.stringify([{ name: 'url', type: 'string' }]),
operations: JSON.stringify([{ name: 'execute' }]),
credentials_required: JSON.stringify([{ name: 'httpBasicAuth' }]),
documentation: 'HTTP docs',
outputs: null,
output_names: null
};
mockAdapter._setMockData('node:nodes-base.httpRequest', mockRow);
const result = repository.getNode('nodes-base.httpRequest');
expect(result).toEqual({
nodeType: 'nodes-base.httpRequest',
displayName: 'HTTP Request',
description: 'Makes HTTP requests',
category: 'transform',
developmentStyle: 'declarative',
package: 'n8n-nodes-base',
isAITool: false,
isTrigger: false,
isWebhook: false,
isVersioned: true,
version: '1.0',
properties: [{ name: 'url', type: 'string' }],
operations: [{ name: 'execute' }],
credentials: [{ name: 'httpBasicAuth' }],
hasDocumentation: true,
outputs: null,
outputNames: null
});
});
it('should return null for non-existent nodes', () => {
const result = repository.getNode('non-existent');
expect(result).toBeNull();
});
it('should handle invalid JSON gracefully', () => {
const mockRow = {
node_type: 'nodes-base.broken',
display_name: 'Broken Node',
description: 'Node with broken JSON',
category: 'transform',
development_style: 'declarative',
package_name: 'n8n-nodes-base',
is_ai_tool: 0,
is_trigger: 0,
is_webhook: 0,
is_versioned: 0,
version: null,
properties_schema: '{invalid json',
operations: 'not json at all',
credentials_required: '{"valid": "json"}',
documentation: null,
outputs: null,
output_names: null
};
mockAdapter._setMockData('node:nodes-base.broken', mockRow);
const result = repository.getNode('nodes-base.broken');
expect(result?.properties).toEqual([]); // defaultValue from safeJsonParse
expect(result?.operations).toEqual([]); // defaultValue from safeJsonParse
expect(result?.credentials).toEqual({ valid: 'json' }); // successfully parsed
});
});
describe('getAITools', () => {
it('should retrieve all AI tools sorted by display name', () => {
const mockAITools = [
{
node_type: 'nodes-base.openai',
display_name: 'OpenAI',
description: 'OpenAI integration',
package_name: 'n8n-nodes-base'
},
{
node_type: 'nodes-base.agent',
display_name: 'AI Agent',
description: 'AI Agent node',
package_name: '@n8n/n8n-nodes-langchain'
}
];
mockAdapter._setMockData('ai_tools', mockAITools);
const result = repository.getAITools();
expect(result).toEqual([
{
nodeType: 'nodes-base.openai',
displayName: 'OpenAI',
description: 'OpenAI integration',
package: 'n8n-nodes-base'
},
{
nodeType: 'nodes-base.agent',
displayName: 'AI Agent',
description: 'AI Agent node',
package: '@n8n/n8n-nodes-langchain'
}
]);
});
it('should return empty array when no AI tools exist', () => {
mockAdapter._setMockData('ai_tools', []);
const result = repository.getAITools();
expect(result).toEqual([]);
});
});
describe('safeJsonParse', () => {
it('should parse valid JSON', () => {
// Access private method through the class
const parseMethod = (repository as any).safeJsonParse.bind(repository);
const validJson = '{"key": "value", "number": 42}';
const result = parseMethod(validJson, {});
expect(result).toEqual({ key: 'value', number: 42 });
});
it('should return default value for invalid JSON', () => {
const parseMethod = (repository as any).safeJsonParse.bind(repository);
const invalidJson = '{invalid json}';
const defaultValue = { default: true };
const result = parseMethod(invalidJson, defaultValue);
expect(result).toEqual(defaultValue);
});
it('should handle empty strings', () => {
const parseMethod = (repository as any).safeJsonParse.bind(repository);
const result = parseMethod('', []);
expect(result).toEqual([]);
});
it('should handle null and undefined', () => {
const parseMethod = (repository as any).safeJsonParse.bind(repository);
// JSON.parse(null) returns null, not an error
expect(parseMethod(null, 'default')).toBe(null);
expect(parseMethod(undefined, 'default')).toBe('default');
});
});
describe('Edge Cases', () => {
it('should handle very large JSON properties', () => {
const largeProperties = Array(1000).fill(null).map((_, i) => ({
name: `prop${i}`,
type: 'string',
description: 'A'.repeat(100)
}));
const node: ParsedNode = {
nodeType: 'nodes-base.large',
displayName: 'Large Node',
category: 'test',
style: 'declarative',
packageName: 'test',
properties: largeProperties,
operations: [],
credentials: [],
isAITool: false,
isTrigger: false,
isWebhook: false,
isVersioned: false,
outputs: undefined,
outputNames: undefined
};
repository.saveNode(node);
const stmt = mockAdapter._getStatement(mockAdapter.prepare.mock.lastCall?.[0] || '');
const runCall = stmt?.run.mock.lastCall;
const savedProperties = runCall?.[12];
expect(savedProperties).toBe(JSON.stringify(largeProperties, null, 2));
});
it('should handle boolean conversion for integer fields', () => {
const mockRow = {
node_type: 'nodes-base.bool-test',
display_name: 'Bool Test',
description: 'Testing boolean conversion',
category: 'test',
development_style: 'declarative',
package_name: 'test',
is_ai_tool: 1,
is_trigger: 0,
is_webhook: '1', // String that should be converted
is_versioned: '0', // String that should be converted
version: null,
properties_schema: '[]',
operations: '[]',
credentials_required: '[]',
documentation: null,
outputs: null,
output_names: null
};
mockAdapter._setMockData('node:nodes-base.bool-test', mockRow);
const result = repository.getNode('nodes-base.bool-test');
expect(result?.isAITool).toBe(true);
expect(result?.isTrigger).toBe(false);
expect(result?.isWebhook).toBe(true);
expect(result?.isVersioned).toBe(false);
});
});
});