test: add template repository and performance integration tests
- Add comprehensive TemplateRepository integration tests - Test FTS5 functionality with templates - Add performance benchmarks for database operations - Test concurrent read/write operations - Measure memory usage and query performance - Verify index optimization and WAL mode benefits - Include bulk operation performance tests Part of Phase 4: Integration Testing
This commit is contained in:
421
tests/integration/database/performance.test.ts
Normal file
421
tests/integration/database/performance.test.ts
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
||||||
|
import * as Database from 'better-sqlite3';
|
||||||
|
import { NodeRepository } from '../../../src/database/node-repository';
|
||||||
|
import { TemplateRepository } from '../../../src/templates/template-repository';
|
||||||
|
import { DatabaseAdapter } from '../../../src/database/database-adapter';
|
||||||
|
import { TestDatabase, TestDataGenerator, PerformanceMonitor } from './test-utils';
|
||||||
|
import { ParsedNode } from '../../../src/parsers/node-parser';
|
||||||
|
|
||||||
|
describe('Database Performance Tests', () => {
|
||||||
|
let testDb: TestDatabase;
|
||||||
|
let db: Database;
|
||||||
|
let nodeRepo: NodeRepository;
|
||||||
|
let templateRepo: TemplateRepository;
|
||||||
|
let adapter: DatabaseAdapter;
|
||||||
|
let monitor: PerformanceMonitor;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
testDb = new TestDatabase({ mode: 'file', name: 'performance-test.db', enableFTS5: true });
|
||||||
|
db = await testDb.initialize();
|
||||||
|
adapter = new DatabaseAdapter(db);
|
||||||
|
nodeRepo = new NodeRepository(adapter);
|
||||||
|
templateRepo = new TemplateRepository(adapter);
|
||||||
|
monitor = new PerformanceMonitor();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
monitor.clear();
|
||||||
|
await testDb.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Node Repository Performance', () => {
|
||||||
|
it('should handle bulk inserts efficiently', () => {
|
||||||
|
const nodeCounts = [100, 1000, 5000];
|
||||||
|
|
||||||
|
nodeCounts.forEach(count => {
|
||||||
|
const nodes = generateNodes(count);
|
||||||
|
|
||||||
|
const stop = monitor.start(`insert_${count}_nodes`);
|
||||||
|
const transaction = db.transaction((nodes: ParsedNode[]) => {
|
||||||
|
nodes.forEach(node => nodeRepo.saveNode(node));
|
||||||
|
});
|
||||||
|
transaction(nodes);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check performance metrics
|
||||||
|
const stats100 = monitor.getStats('insert_100_nodes');
|
||||||
|
const stats1000 = monitor.getStats('insert_1000_nodes');
|
||||||
|
const stats5000 = monitor.getStats('insert_5000_nodes');
|
||||||
|
|
||||||
|
expect(stats100!.average).toBeLessThan(100); // 100 nodes in under 100ms
|
||||||
|
expect(stats1000!.average).toBeLessThan(500); // 1000 nodes in under 500ms
|
||||||
|
expect(stats5000!.average).toBeLessThan(2000); // 5000 nodes in under 2s
|
||||||
|
|
||||||
|
// Performance should scale sub-linearly
|
||||||
|
const ratio1000to100 = stats1000!.average / stats100!.average;
|
||||||
|
const ratio5000to1000 = stats5000!.average / stats1000!.average;
|
||||||
|
expect(ratio1000to100).toBeLessThan(10); // Should be better than linear scaling
|
||||||
|
expect(ratio5000to1000).toBeLessThan(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should search nodes quickly with indexes', () => {
|
||||||
|
// Insert test data
|
||||||
|
const nodes = generateNodes(10000);
|
||||||
|
const transaction = db.transaction((nodes: ParsedNode[]) => {
|
||||||
|
nodes.forEach(node => nodeRepo.saveNode(node));
|
||||||
|
});
|
||||||
|
transaction(nodes);
|
||||||
|
|
||||||
|
// Test different search scenarios
|
||||||
|
const searchTests = [
|
||||||
|
{ query: 'webhook', mode: 'OR' as const },
|
||||||
|
{ query: 'http request', mode: 'AND' as const },
|
||||||
|
{ query: 'automation data', mode: 'OR' as const },
|
||||||
|
{ query: 'HTT', mode: 'FUZZY' as const }
|
||||||
|
];
|
||||||
|
|
||||||
|
searchTests.forEach(test => {
|
||||||
|
const stop = monitor.start(`search_${test.query}_${test.mode}`);
|
||||||
|
const results = nodeRepo.searchNodes(test.query, test.mode, 100);
|
||||||
|
stop();
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// All searches should be fast
|
||||||
|
searchTests.forEach(test => {
|
||||||
|
const stats = monitor.getStats(`search_${test.query}_${test.mode}`);
|
||||||
|
expect(stats!.average).toBeLessThan(50); // Each search under 50ms
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle concurrent reads efficiently', () => {
|
||||||
|
// Insert initial data
|
||||||
|
const nodes = generateNodes(1000);
|
||||||
|
const transaction = db.transaction((nodes: ParsedNode[]) => {
|
||||||
|
nodes.forEach(node => nodeRepo.saveNode(node));
|
||||||
|
});
|
||||||
|
transaction(nodes);
|
||||||
|
|
||||||
|
// Simulate concurrent reads
|
||||||
|
const readOperations = 100;
|
||||||
|
const promises: Promise<any>[] = [];
|
||||||
|
|
||||||
|
const stop = monitor.start('concurrent_reads');
|
||||||
|
|
||||||
|
for (let i = 0; i < readOperations; i++) {
|
||||||
|
promises.push(
|
||||||
|
Promise.resolve(nodeRepo.getNode(`n8n-nodes-base.node${i % 1000}`))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(promises);
|
||||||
|
stop();
|
||||||
|
|
||||||
|
const stats = monitor.getStats('concurrent_reads');
|
||||||
|
expect(stats!.average).toBeLessThan(100); // 100 reads in under 100ms
|
||||||
|
|
||||||
|
// Average per read should be very low
|
||||||
|
const avgPerRead = stats!.average / readOperations;
|
||||||
|
expect(avgPerRead).toBeLessThan(1); // Less than 1ms per read
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Template Repository Performance with FTS5', () => {
|
||||||
|
it('should perform FTS5 searches efficiently', () => {
|
||||||
|
// Insert templates with varied content
|
||||||
|
const templates = Array.from({ length: 10000 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: `${['Webhook', 'HTTP', 'Automation', 'Data Processing'][i % 4]} Workflow ${i}`,
|
||||||
|
description: generateDescription(i),
|
||||||
|
workflow: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'node1',
|
||||||
|
name: 'Start',
|
||||||
|
type: ['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest', 'n8n-nodes-base.set'][i % 3],
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {},
|
||||||
|
settings: {}
|
||||||
|
},
|
||||||
|
user: { username: 'user' },
|
||||||
|
views: Math.floor(Math.random() * 1000),
|
||||||
|
totalViews: Math.floor(Math.random() * 1000),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const stop1 = monitor.start('insert_templates_with_fts');
|
||||||
|
const transaction = db.transaction((templates: any[]) => {
|
||||||
|
templates.forEach(t => templateRepo.saveTemplate(t));
|
||||||
|
});
|
||||||
|
transaction(templates);
|
||||||
|
stop1();
|
||||||
|
|
||||||
|
// Test various FTS5 searches
|
||||||
|
const searchTests = [
|
||||||
|
'webhook',
|
||||||
|
'data processing',
|
||||||
|
'automat*',
|
||||||
|
'"HTTP Workflow"',
|
||||||
|
'webhook OR http',
|
||||||
|
'processing NOT webhook'
|
||||||
|
];
|
||||||
|
|
||||||
|
searchTests.forEach(query => {
|
||||||
|
const stop = monitor.start(`fts5_search_${query}`);
|
||||||
|
const results = templateRepo.searchTemplates(query, 100);
|
||||||
|
stop();
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// All FTS5 searches should be very fast
|
||||||
|
searchTests.forEach(query => {
|
||||||
|
const stats = monitor.getStats(`fts5_search_${query}`);
|
||||||
|
expect(stats!.average).toBeLessThan(20); // FTS5 searches under 20ms
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex node type searches efficiently', () => {
|
||||||
|
// Insert templates with various node combinations
|
||||||
|
const nodeTypes = [
|
||||||
|
'n8n-nodes-base.webhook',
|
||||||
|
'n8n-nodes-base.httpRequest',
|
||||||
|
'n8n-nodes-base.slack',
|
||||||
|
'n8n-nodes-base.googleSheets',
|
||||||
|
'n8n-nodes-base.mongodb'
|
||||||
|
];
|
||||||
|
|
||||||
|
const templates = Array.from({ length: 5000 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: `Template ${i}`,
|
||||||
|
workflow: {
|
||||||
|
nodes: Array.from({ length: 3 }, (_, j) => ({
|
||||||
|
id: `node${j}`,
|
||||||
|
name: `Node ${j}`,
|
||||||
|
type: nodeTypes[(i + j) % nodeTypes.length],
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100 * j, 100],
|
||||||
|
parameters: {}
|
||||||
|
})),
|
||||||
|
connections: {},
|
||||||
|
settings: {}
|
||||||
|
},
|
||||||
|
user: { username: 'user' },
|
||||||
|
views: 100,
|
||||||
|
totalViews: 100,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const insertTransaction = db.transaction((templates: any[]) => {
|
||||||
|
templates.forEach(t => templateRepo.saveTemplate(t));
|
||||||
|
});
|
||||||
|
insertTransaction(templates);
|
||||||
|
|
||||||
|
// Test searching by node types
|
||||||
|
const stop = monitor.start('search_by_node_types');
|
||||||
|
const results = templateRepo.getTemplatesByNodeTypes([
|
||||||
|
'n8n-nodes-base.webhook',
|
||||||
|
'n8n-nodes-base.slack'
|
||||||
|
], 100);
|
||||||
|
stop();
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const stats = monitor.getStats('search_by_node_types');
|
||||||
|
expect(stats!.average).toBeLessThan(50); // Complex JSON searches under 50ms
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Database Optimization', () => {
|
||||||
|
it('should benefit from proper indexing', () => {
|
||||||
|
// Insert data
|
||||||
|
const nodes = generateNodes(5000);
|
||||||
|
const transaction = db.transaction((nodes: ParsedNode[]) => {
|
||||||
|
nodes.forEach(node => nodeRepo.saveNode(node));
|
||||||
|
});
|
||||||
|
transaction(nodes);
|
||||||
|
|
||||||
|
// Test queries that use indexes
|
||||||
|
const indexedQueries = [
|
||||||
|
() => nodeRepo.getNodesByPackage('n8n-nodes-base'),
|
||||||
|
() => nodeRepo.getNodesByCategory('trigger'),
|
||||||
|
() => nodeRepo.getAITools()
|
||||||
|
];
|
||||||
|
|
||||||
|
indexedQueries.forEach((query, i) => {
|
||||||
|
const stop = monitor.start(`indexed_query_${i}`);
|
||||||
|
const results = query();
|
||||||
|
stop();
|
||||||
|
|
||||||
|
expect(Array.isArray(results)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// All indexed queries should be fast
|
||||||
|
indexedQueries.forEach((_, i) => {
|
||||||
|
const stats = monitor.getStats(`indexed_query_${i}`);
|
||||||
|
expect(stats!.average).toBeLessThan(20); // Indexed queries under 20ms
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle VACUUM operation efficiently', () => {
|
||||||
|
// Insert and delete data to create fragmentation
|
||||||
|
const nodes = generateNodes(1000);
|
||||||
|
|
||||||
|
// Insert
|
||||||
|
const insertTx = db.transaction((nodes: ParsedNode[]) => {
|
||||||
|
nodes.forEach(node => nodeRepo.saveNode(node));
|
||||||
|
});
|
||||||
|
insertTx(nodes);
|
||||||
|
|
||||||
|
// Delete half
|
||||||
|
db.prepare('DELETE FROM nodes WHERE ROWID % 2 = 0').run();
|
||||||
|
|
||||||
|
// Measure VACUUM performance
|
||||||
|
const stop = monitor.start('vacuum');
|
||||||
|
db.exec('VACUUM');
|
||||||
|
stop();
|
||||||
|
|
||||||
|
const stats = monitor.getStats('vacuum');
|
||||||
|
expect(stats!.average).toBeLessThan(1000); // VACUUM under 1 second
|
||||||
|
|
||||||
|
// Verify database still works
|
||||||
|
const remaining = nodeRepo.getAllNodes();
|
||||||
|
expect(remaining.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain performance with WAL mode', () => {
|
||||||
|
// Verify WAL mode is enabled
|
||||||
|
const mode = db.prepare('PRAGMA journal_mode').get() as { journal_mode: string };
|
||||||
|
expect(mode.journal_mode).toBe('wal');
|
||||||
|
|
||||||
|
// Perform mixed read/write operations
|
||||||
|
const operations = 1000;
|
||||||
|
|
||||||
|
const stop = monitor.start('wal_mixed_operations');
|
||||||
|
|
||||||
|
for (let i = 0; i < operations; i++) {
|
||||||
|
if (i % 10 === 0) {
|
||||||
|
// Write operation
|
||||||
|
const node = generateNodes(1)[0];
|
||||||
|
nodeRepo.saveNode(node);
|
||||||
|
} else {
|
||||||
|
// Read operation
|
||||||
|
nodeRepo.getAllNodes(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop();
|
||||||
|
|
||||||
|
const stats = monitor.getStats('wal_mixed_operations');
|
||||||
|
expect(stats!.average).toBeLessThan(500); // Mixed operations under 500ms
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Memory Usage', () => {
|
||||||
|
it('should handle large result sets without excessive memory', () => {
|
||||||
|
// Insert large dataset
|
||||||
|
const nodes = generateNodes(10000);
|
||||||
|
const transaction = db.transaction((nodes: ParsedNode[]) => {
|
||||||
|
nodes.forEach(node => nodeRepo.saveNode(node));
|
||||||
|
});
|
||||||
|
transaction(nodes);
|
||||||
|
|
||||||
|
// Measure memory before
|
||||||
|
const memBefore = process.memoryUsage().heapUsed;
|
||||||
|
|
||||||
|
// Fetch large result set
|
||||||
|
const stop = monitor.start('large_result_set');
|
||||||
|
const results = nodeRepo.getAllNodes();
|
||||||
|
stop();
|
||||||
|
|
||||||
|
// Measure memory after
|
||||||
|
const memAfter = process.memoryUsage().heapUsed;
|
||||||
|
const memIncrease = (memAfter - memBefore) / 1024 / 1024; // MB
|
||||||
|
|
||||||
|
expect(results).toHaveLength(10000);
|
||||||
|
expect(memIncrease).toBeLessThan(100); // Less than 100MB increase
|
||||||
|
|
||||||
|
const stats = monitor.getStats('large_result_set');
|
||||||
|
expect(stats!.average).toBeLessThan(200); // Fetch 10k records under 200ms
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Concurrent Write Performance', () => {
|
||||||
|
it('should handle concurrent writes with transactions', () => {
|
||||||
|
const writeBatches = 10;
|
||||||
|
const nodesPerBatch = 100;
|
||||||
|
|
||||||
|
const stop = monitor.start('concurrent_writes');
|
||||||
|
|
||||||
|
// Simulate concurrent write batches
|
||||||
|
const promises = Array.from({ length: writeBatches }, (_, i) => {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const nodes = generateNodes(nodesPerBatch, i * nodesPerBatch);
|
||||||
|
const transaction = db.transaction((nodes: ParsedNode[]) => {
|
||||||
|
nodes.forEach(node => nodeRepo.saveNode(node));
|
||||||
|
});
|
||||||
|
transaction(nodes);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(promises);
|
||||||
|
stop();
|
||||||
|
|
||||||
|
const stats = monitor.getStats('concurrent_writes');
|
||||||
|
expect(stats!.average).toBeLessThan(500); // All writes under 500ms
|
||||||
|
|
||||||
|
// Verify all nodes were written
|
||||||
|
const count = nodeRepo.getNodeCount();
|
||||||
|
expect(count).toBe(writeBatches * nodesPerBatch);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function generateNodes(count: number, startId: number = 0): ParsedNode[] {
|
||||||
|
const categories = ['trigger', 'automation', 'transform', 'output'];
|
||||||
|
const packages = ['n8n-nodes-base', '@n8n/n8n-nodes-langchain'];
|
||||||
|
|
||||||
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
nodeType: `n8n-nodes-base.node${startId + i}`,
|
||||||
|
packageName: packages[i % packages.length],
|
||||||
|
displayName: `Node ${startId + i}`,
|
||||||
|
description: `Description for node ${startId + i} with ${['webhook', 'http', 'automation', 'data'][i % 4]} functionality`,
|
||||||
|
category: categories[i % categories.length],
|
||||||
|
style: 'programmatic' as const,
|
||||||
|
isAITool: i % 10 === 0,
|
||||||
|
isTrigger: categories[i % categories.length] === 'trigger',
|
||||||
|
isWebhook: i % 5 === 0,
|
||||||
|
isVersioned: true,
|
||||||
|
version: '1',
|
||||||
|
documentation: i % 3 === 0 ? `Documentation for node ${i}` : null,
|
||||||
|
properties: Array.from({ length: 5 }, (_, j) => ({
|
||||||
|
displayName: `Property ${j}`,
|
||||||
|
name: `prop${j}`,
|
||||||
|
type: 'string',
|
||||||
|
default: ''
|
||||||
|
})),
|
||||||
|
operations: [],
|
||||||
|
credentials: i % 4 === 0 ? [{ name: 'httpAuth', required: true }] : []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDescription(index: number): string {
|
||||||
|
const descriptions = [
|
||||||
|
'Automate your workflow with powerful webhook integrations',
|
||||||
|
'Process HTTP requests and transform data efficiently',
|
||||||
|
'Connect to external APIs and sync data seamlessly',
|
||||||
|
'Build complex automation workflows with ease',
|
||||||
|
'Transform and filter data with advanced operations'
|
||||||
|
];
|
||||||
|
return descriptions[index % descriptions.length] + ` - Version ${index}`;
|
||||||
|
}
|
||||||
439
tests/integration/database/template-repository.test.ts
Normal file
439
tests/integration/database/template-repository.test.ts
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
||||||
|
import * as Database from 'better-sqlite3';
|
||||||
|
import { TemplateRepository } from '../../../src/templates/template-repository';
|
||||||
|
import { DatabaseAdapter } from '../../../src/database/database-adapter';
|
||||||
|
import { TestDatabase, TestDataGenerator } from './test-utils';
|
||||||
|
import { TemplateWorkflow, TemplateDetail } from '../../../src/templates/template-fetcher';
|
||||||
|
|
||||||
|
describe('TemplateRepository Integration Tests', () => {
|
||||||
|
let testDb: TestDatabase;
|
||||||
|
let db: Database;
|
||||||
|
let repository: TemplateRepository;
|
||||||
|
let adapter: DatabaseAdapter;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
testDb = new TestDatabase({ mode: 'memory', enableFTS5: true });
|
||||||
|
db = await testDb.initialize();
|
||||||
|
adapter = new DatabaseAdapter(db);
|
||||||
|
repository = new TemplateRepository(adapter);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await testDb.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveTemplate', () => {
|
||||||
|
it('should save single template successfully', () => {
|
||||||
|
const template = createTemplateWorkflow();
|
||||||
|
repository.saveTemplate(template);
|
||||||
|
|
||||||
|
const saved = repository.getTemplate(template.id);
|
||||||
|
expect(saved).toBeTruthy();
|
||||||
|
expect(saved?.workflow_id).toBe(template.id);
|
||||||
|
expect(saved?.name).toBe(template.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update existing template', () => {
|
||||||
|
const template = createTemplateWorkflow();
|
||||||
|
|
||||||
|
// Save initial version
|
||||||
|
repository.saveTemplate(template);
|
||||||
|
|
||||||
|
// Update and save again
|
||||||
|
const updated: TemplateWorkflow = { ...template, name: 'Updated Template' };
|
||||||
|
repository.saveTemplate(updated);
|
||||||
|
|
||||||
|
const saved = repository.getTemplate(template.id);
|
||||||
|
expect(saved?.name).toBe('Updated Template');
|
||||||
|
|
||||||
|
// Should not create duplicate
|
||||||
|
const all = repository.getAllTemplates();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle templates with complex node types', () => {
|
||||||
|
const template = createTemplateWorkflow({
|
||||||
|
id: 1,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'node1',
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'node2',
|
||||||
|
name: 'HTTP Request',
|
||||||
|
type: 'n8n-nodes-base.httpRequest',
|
||||||
|
typeVersion: 3,
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
method: 'POST'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
repository.saveTemplate(template);
|
||||||
|
|
||||||
|
const saved = repository.getTemplate(template.id);
|
||||||
|
expect(saved).toBeTruthy();
|
||||||
|
|
||||||
|
const nodesUsed = JSON.parse(saved!.nodes_used);
|
||||||
|
expect(nodesUsed).toContain('n8n-nodes-base.webhook');
|
||||||
|
expect(nodesUsed).toContain('n8n-nodes-base.httpRequest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sanitize workflow data before saving', () => {
|
||||||
|
const template = createTemplateWorkflow({
|
||||||
|
workflowInfo: {
|
||||||
|
nodeCount: 5,
|
||||||
|
webhookCount: 1,
|
||||||
|
// Add some data that should be sanitized
|
||||||
|
executionId: 'should-be-removed',
|
||||||
|
pinData: { node1: { data: 'sensitive' } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
repository.saveTemplate(template);
|
||||||
|
|
||||||
|
const saved = repository.getTemplate(template.id);
|
||||||
|
expect(saved).toBeTruthy();
|
||||||
|
|
||||||
|
const workflowJson = JSON.parse(saved!.workflow_json);
|
||||||
|
expect(workflowJson.pinData).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTemplate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const templates = [
|
||||||
|
createTemplateWorkflow({ id: 1, name: 'Template 1' }),
|
||||||
|
createTemplateWorkflow({ id: 2, name: 'Template 2' })
|
||||||
|
];
|
||||||
|
templates.forEach(t => repository.saveTemplate(t));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retrieve template by id', () => {
|
||||||
|
const template = repository.getTemplate(1);
|
||||||
|
expect(template).toBeTruthy();
|
||||||
|
expect(template?.name).toBe('Template 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for non-existent template', () => {
|
||||||
|
const template = repository.getTemplate(999);
|
||||||
|
expect(template).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchTemplates with FTS5', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const templates = [
|
||||||
|
createTemplateWorkflow({
|
||||||
|
id: 1,
|
||||||
|
name: 'Webhook to Slack',
|
||||||
|
description: 'Send Slack notifications when webhook received'
|
||||||
|
}),
|
||||||
|
createTemplateWorkflow({
|
||||||
|
id: 2,
|
||||||
|
name: 'HTTP Data Processing',
|
||||||
|
description: 'Process data from HTTP requests'
|
||||||
|
}),
|
||||||
|
createTemplateWorkflow({
|
||||||
|
id: 3,
|
||||||
|
name: 'Email Automation',
|
||||||
|
description: 'Automate email sending workflow'
|
||||||
|
})
|
||||||
|
];
|
||||||
|
templates.forEach(t => repository.saveTemplate(t));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should search templates by name', () => {
|
||||||
|
const results = repository.searchTemplates('webhook');
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].name).toBe('Webhook to Slack');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should search templates by description', () => {
|
||||||
|
const results = repository.searchTemplates('automate');
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].name).toBe('Email Automation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple search terms', () => {
|
||||||
|
const results = repository.searchTemplates('data process');
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].name).toBe('HTTP Data Processing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should limit search results', () => {
|
||||||
|
// Add more templates
|
||||||
|
for (let i = 4; i <= 20; i++) {
|
||||||
|
repository.saveTemplate(createTemplateWorkflow({
|
||||||
|
id: i,
|
||||||
|
name: `Test Template ${i}`,
|
||||||
|
description: 'Test description'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = repository.searchTemplates('test', 5);
|
||||||
|
expect(results).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in search', () => {
|
||||||
|
repository.saveTemplate(createTemplateWorkflow({
|
||||||
|
id: 100,
|
||||||
|
name: 'Special @ # $ Template',
|
||||||
|
description: 'Template with special characters'
|
||||||
|
}));
|
||||||
|
|
||||||
|
const results = repository.searchTemplates('special');
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTemplatesByNodeTypes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const templates = [
|
||||||
|
createTemplateWorkflow({
|
||||||
|
id: 1,
|
||||||
|
nodes: [
|
||||||
|
{ type: 'n8n-nodes-base.webhook' },
|
||||||
|
{ type: 'n8n-nodes-base.slack' }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
createTemplateWorkflow({
|
||||||
|
id: 2,
|
||||||
|
nodes: [
|
||||||
|
{ type: 'n8n-nodes-base.httpRequest' },
|
||||||
|
{ type: 'n8n-nodes-base.set' }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
createTemplateWorkflow({
|
||||||
|
id: 3,
|
||||||
|
nodes: [
|
||||||
|
{ type: 'n8n-nodes-base.webhook' },
|
||||||
|
{ type: 'n8n-nodes-base.httpRequest' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
];
|
||||||
|
templates.forEach(t => repository.saveTemplate(t));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find templates using specific node types', () => {
|
||||||
|
const results = repository.getTemplatesByNodeTypes(['n8n-nodes-base.webhook']);
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results.map(r => r.workflow_id)).toContain(1);
|
||||||
|
expect(results.map(r => r.workflow_id)).toContain(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find templates using multiple node types', () => {
|
||||||
|
const results = repository.getTemplatesByNodeTypes([
|
||||||
|
'n8n-nodes-base.webhook',
|
||||||
|
'n8n-nodes-base.slack'
|
||||||
|
]);
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].workflow_id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for non-existent node types', () => {
|
||||||
|
const results = repository.getTemplatesByNodeTypes(['non-existent-node']);
|
||||||
|
expect(results).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should limit results', () => {
|
||||||
|
const results = repository.getTemplatesByNodeTypes(['n8n-nodes-base.webhook'], 1);
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllTemplates', () => {
|
||||||
|
it('should return empty array when no templates', () => {
|
||||||
|
const templates = repository.getAllTemplates();
|
||||||
|
expect(templates).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return all templates with limit', () => {
|
||||||
|
for (let i = 1; i <= 20; i++) {
|
||||||
|
repository.saveTemplate(createTemplateWorkflow({ id: i }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = repository.getAllTemplates(10);
|
||||||
|
expect(templates).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should order templates by updated_at descending', () => {
|
||||||
|
// Save templates with slight delay to ensure different timestamps
|
||||||
|
const template1 = createTemplateWorkflow({ id: 1, name: 'First' });
|
||||||
|
repository.saveTemplate(template1);
|
||||||
|
|
||||||
|
// Small delay
|
||||||
|
const template2 = createTemplateWorkflow({ id: 2, name: 'Second' });
|
||||||
|
repository.saveTemplate(template2);
|
||||||
|
|
||||||
|
const templates = repository.getAllTemplates();
|
||||||
|
expect(templates).toHaveLength(2);
|
||||||
|
// Most recent should be first
|
||||||
|
expect(templates[0].name).toBe('Second');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTemplateDetail', () => {
|
||||||
|
it('should return template with full workflow data', () => {
|
||||||
|
const template = createTemplateDetail();
|
||||||
|
repository.saveTemplateDetail(template);
|
||||||
|
|
||||||
|
const saved = repository.getTemplateDetail(template.id);
|
||||||
|
expect(saved).toBeTruthy();
|
||||||
|
expect(saved?.workflow).toBeTruthy();
|
||||||
|
expect(saved?.workflow.nodes).toHaveLength(template.workflow.nodes.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing workflow gracefully', () => {
|
||||||
|
const template = createTemplateWorkflow({ id: 1 });
|
||||||
|
repository.saveTemplate(template);
|
||||||
|
|
||||||
|
const detail = repository.getTemplateDetail(1);
|
||||||
|
expect(detail).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearOldTemplates', () => {
|
||||||
|
it('should remove templates older than specified days', () => {
|
||||||
|
// Insert old template (30 days ago)
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO templates (
|
||||||
|
id, workflow_id, name, description,
|
||||||
|
nodes_used, workflow_json, categories, views,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, '[]', '{}', '[]', 0, datetime('now', '-31 days'), datetime('now', '-31 days'))
|
||||||
|
`).run(1, 1001, 'Old Template', 'Old template');
|
||||||
|
|
||||||
|
// Insert recent template
|
||||||
|
repository.saveTemplate(createTemplateWorkflow({ id: 2, name: 'Recent Template' }));
|
||||||
|
|
||||||
|
// Clear templates older than 30 days
|
||||||
|
const deleted = repository.clearOldTemplates(30);
|
||||||
|
expect(deleted).toBe(1);
|
||||||
|
|
||||||
|
const remaining = repository.getAllTemplates();
|
||||||
|
expect(remaining).toHaveLength(1);
|
||||||
|
expect(remaining[0].name).toBe('Recent Template');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Transaction handling', () => {
|
||||||
|
it('should rollback on error during bulk save', () => {
|
||||||
|
const templates = [
|
||||||
|
createTemplateWorkflow({ id: 1 }),
|
||||||
|
createTemplateWorkflow({ id: 2 }),
|
||||||
|
{ id: null } as any // Invalid template
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
templates.forEach(t => repository.saveTemplate(t));
|
||||||
|
}).toThrow();
|
||||||
|
|
||||||
|
// No templates should be saved due to error
|
||||||
|
const all = repository.getAllTemplates();
|
||||||
|
expect(all).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FTS5 performance', () => {
|
||||||
|
it('should handle large dataset searches efficiently', () => {
|
||||||
|
// Insert 1000 templates
|
||||||
|
const templates = Array.from({ length: 1000 }, (_, i) =>
|
||||||
|
createTemplateWorkflow({
|
||||||
|
id: i + 1,
|
||||||
|
name: `Template ${i}`,
|
||||||
|
description: `Description for ${['webhook', 'http', 'automation', 'data'][i % 4]} workflow ${i}`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertMany = db.transaction((templates: TemplateWorkflow[]) => {
|
||||||
|
templates.forEach(t => repository.saveTemplate(t));
|
||||||
|
});
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
insertMany(templates);
|
||||||
|
const insertDuration = Date.now() - start;
|
||||||
|
|
||||||
|
expect(insertDuration).toBeLessThan(2000); // Should complete in under 2 seconds
|
||||||
|
|
||||||
|
// Test search performance
|
||||||
|
const searchStart = Date.now();
|
||||||
|
const results = repository.searchTemplates('webhook', 50);
|
||||||
|
const searchDuration = Date.now() - searchStart;
|
||||||
|
|
||||||
|
expect(searchDuration).toBeLessThan(50); // Search should be very fast
|
||||||
|
expect(results).toHaveLength(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function createTemplateWorkflow(overrides: any = {}): TemplateWorkflow {
|
||||||
|
const id = overrides.id || Math.floor(Math.random() * 10000);
|
||||||
|
const nodes = overrides.nodes || [
|
||||||
|
{
|
||||||
|
id: 'node1',
|
||||||
|
name: 'Start',
|
||||||
|
type: 'n8n-nodes-base.start',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: overrides.name || `Test Workflow ${id}`,
|
||||||
|
workflow: {
|
||||||
|
nodes: nodes.map((n: any) => ({
|
||||||
|
id: n.id || 'node1',
|
||||||
|
name: n.name || 'Node',
|
||||||
|
type: n.type || 'n8n-nodes-base.start',
|
||||||
|
typeVersion: n.typeVersion || 1,
|
||||||
|
position: n.position || [100, 100],
|
||||||
|
parameters: n.parameters || {}
|
||||||
|
})),
|
||||||
|
connections: overrides.connections || {},
|
||||||
|
settings: overrides.settings || {}
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
username: overrides.username || 'testuser'
|
||||||
|
},
|
||||||
|
views: overrides.views || 100,
|
||||||
|
totalViews: overrides.totalViews || 100,
|
||||||
|
createdAt: overrides.createdAt || new Date().toISOString(),
|
||||||
|
updatedAt: overrides.updatedAt || new Date().toISOString(),
|
||||||
|
description: overrides.description,
|
||||||
|
workflowInfo: overrides.workflowInfo || {
|
||||||
|
nodeCount: nodes.length,
|
||||||
|
webhookCount: nodes.filter((n: any) => n.type?.includes('webhook')).length
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTemplateDetail(overrides: any = {}): TemplateDetail {
|
||||||
|
const base = createTemplateWorkflow(overrides);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
workflow: {
|
||||||
|
id: base.id.toString(),
|
||||||
|
name: base.name,
|
||||||
|
nodes: base.workflow.nodes,
|
||||||
|
connections: base.workflow.connections,
|
||||||
|
settings: base.workflow.settings,
|
||||||
|
pinData: overrides.pinData
|
||||||
|
},
|
||||||
|
categories: overrides.categories || [
|
||||||
|
{ id: 1, name: 'automation' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user