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