Major improvements based on comprehensive test suite review: Test Fixes: - Fix all 78 failing tests across logger, MSW, and validator tests - Fix console spy management in logger tests with proper DEBUG env handling - Fix MSW test environment restoration in session-management.test.ts - Fix workflow validator tests by adding proper node connections - Fix mock setup issues in edge case tests Test Organization: - Split large config-validator.test.ts (1,075 lines) into 4 focused files - Rename 63+ tests to follow "should X when Y" naming convention - Add comprehensive edge case test files for all major validators - Create tests/README.md with testing guidelines and best practices New Features: - Add ConfigValidator.validateBatch() method for bulk validation - Add edge case coverage for null/undefined, boundaries, invalid data - Add CI-aware performance test timeouts - Add JSDoc comments to test utilities and factories - Add workflow duplicate node name validation tests Results: - All tests passing: 1,356 passed, 19 skipped - Test coverage: 85.34% statements, 85.3% branches - From 78 failures to 0 failures 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
594 lines
17 KiB
TypeScript
594 lines
17 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import Database from 'better-sqlite3';
|
|
import { execSync } from 'child_process';
|
|
import type { DatabaseAdapter } from '../../../src/database/database-adapter';
|
|
|
|
/**
|
|
* Configuration options for creating test databases
|
|
*/
|
|
export interface TestDatabaseOptions {
|
|
/** Database mode - in-memory for fast tests, file for persistence tests */
|
|
mode: 'memory' | 'file';
|
|
/** Custom database filename (only for file mode) */
|
|
name?: string;
|
|
/** Enable Write-Ahead Logging for better concurrency (file mode only) */
|
|
enableWAL?: boolean;
|
|
/** Enable FTS5 full-text search extension */
|
|
enableFTS5?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Test database utility for creating isolated database instances for testing.
|
|
* Provides automatic schema setup, cleanup, and various helper methods.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* // Create in-memory database for unit tests
|
|
* const testDb = await TestDatabase.createIsolated({ mode: 'memory' });
|
|
* const db = testDb.getDatabase();
|
|
* // ... run tests
|
|
* await testDb.cleanup();
|
|
*
|
|
* // Create file-based database for integration tests
|
|
* const testDb = await TestDatabase.createIsolated({
|
|
* mode: 'file',
|
|
* enableWAL: true
|
|
* });
|
|
* ```
|
|
*/
|
|
export class TestDatabase {
|
|
private db: Database.Database | null = null;
|
|
private dbPath?: string;
|
|
private options: TestDatabaseOptions;
|
|
|
|
constructor(options: TestDatabaseOptions = { mode: 'memory' }) {
|
|
this.options = options;
|
|
}
|
|
|
|
/**
|
|
* Creates an isolated test database instance with automatic cleanup.
|
|
* Each instance gets a unique name to prevent conflicts in parallel tests.
|
|
*
|
|
* @param options - Database configuration options
|
|
* @returns Promise resolving to initialized TestDatabase instance
|
|
*/
|
|
static async createIsolated(options: TestDatabaseOptions = { mode: 'memory' }): Promise<TestDatabase> {
|
|
const testDb = new TestDatabase({
|
|
...options,
|
|
name: options.name || `isolated-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.db`
|
|
});
|
|
await testDb.initialize();
|
|
return testDb;
|
|
}
|
|
|
|
async initialize(): Promise<Database.Database> {
|
|
if (this.db) return this.db;
|
|
|
|
if (this.options.mode === 'file') {
|
|
const testDir = path.join(__dirname, '../../../.test-dbs');
|
|
if (!fs.existsSync(testDir)) {
|
|
fs.mkdirSync(testDir, { recursive: true });
|
|
}
|
|
this.dbPath = path.join(testDir, this.options.name || `test-${Date.now()}.db`);
|
|
this.db = new Database(this.dbPath);
|
|
} else {
|
|
this.db = new Database(':memory:');
|
|
}
|
|
|
|
// Enable WAL mode for file databases
|
|
if (this.options.mode === 'file' && this.options.enableWAL !== false) {
|
|
this.db.exec('PRAGMA journal_mode = WAL');
|
|
}
|
|
|
|
// Load FTS5 extension if requested
|
|
if (this.options.enableFTS5) {
|
|
// FTS5 is built into SQLite by default in better-sqlite3
|
|
try {
|
|
this.db.exec('CREATE VIRTUAL TABLE test_fts USING fts5(content)');
|
|
this.db.exec('DROP TABLE test_fts');
|
|
} catch (error) {
|
|
throw new Error('FTS5 extension not available');
|
|
}
|
|
}
|
|
|
|
// Apply schema
|
|
await this.applySchema();
|
|
|
|
return this.db;
|
|
}
|
|
|
|
private async applySchema(): Promise<void> {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
|
|
const schemaPath = path.join(__dirname, '../../../src/database/schema.sql');
|
|
const schema = fs.readFileSync(schemaPath, 'utf-8');
|
|
|
|
// Execute schema statements one by one
|
|
const statements = schema
|
|
.split(';')
|
|
.map(s => s.trim())
|
|
.filter(s => s.length > 0);
|
|
|
|
for (const statement of statements) {
|
|
this.db.exec(statement);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the underlying better-sqlite3 database instance.
|
|
* @throws Error if database is not initialized
|
|
* @returns The database instance
|
|
*/
|
|
getDatabase(): Database.Database {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
return this.db;
|
|
}
|
|
|
|
/**
|
|
* Cleans up the database connection and removes any created files.
|
|
* Should be called in afterEach/afterAll hooks to prevent resource leaks.
|
|
*/
|
|
async cleanup(): Promise<void> {
|
|
if (this.db) {
|
|
this.db.close();
|
|
this.db = null;
|
|
}
|
|
|
|
if (this.dbPath && fs.existsSync(this.dbPath)) {
|
|
fs.unlinkSync(this.dbPath);
|
|
// Also remove WAL and SHM files if they exist
|
|
const walPath = `${this.dbPath}-wal`;
|
|
const shmPath = `${this.dbPath}-shm`;
|
|
if (fs.existsSync(walPath)) fs.unlinkSync(walPath);
|
|
if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if the database is currently locked by another process.
|
|
* Useful for testing concurrent access scenarios.
|
|
*
|
|
* @returns true if database is locked, false otherwise
|
|
*/
|
|
isLocked(): boolean {
|
|
if (!this.db) return false;
|
|
try {
|
|
this.db.exec('BEGIN IMMEDIATE');
|
|
this.db.exec('ROLLBACK');
|
|
return false;
|
|
} catch (error: any) {
|
|
return error.code === 'SQLITE_BUSY';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Performance monitoring utility for measuring test execution times.
|
|
* Collects timing data and provides statistical analysis.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const monitor = new PerformanceMonitor();
|
|
*
|
|
* // Measure single operation
|
|
* const stop = monitor.start('database-query');
|
|
* await db.query('SELECT * FROM nodes');
|
|
* stop();
|
|
*
|
|
* // Get statistics
|
|
* const stats = monitor.getStats('database-query');
|
|
* console.log(`Average: ${stats.average}ms`);
|
|
* ```
|
|
*/
|
|
export class PerformanceMonitor {
|
|
private measurements: Map<string, number[]> = new Map();
|
|
|
|
/**
|
|
* Starts timing for a labeled operation.
|
|
* Returns a function that should be called to stop timing.
|
|
*
|
|
* @param label - Unique label for the operation being measured
|
|
* @returns Stop function to call when operation completes
|
|
*/
|
|
start(label: string): () => void {
|
|
const startTime = process.hrtime.bigint();
|
|
return () => {
|
|
const endTime = process.hrtime.bigint();
|
|
const duration = Number(endTime - startTime) / 1_000_000; // Convert to milliseconds
|
|
|
|
if (!this.measurements.has(label)) {
|
|
this.measurements.set(label, []);
|
|
}
|
|
this.measurements.get(label)!.push(duration);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Gets statistical analysis of all measurements for a given label.
|
|
*
|
|
* @param label - The operation label to get stats for
|
|
* @returns Statistics object or null if no measurements exist
|
|
*/
|
|
getStats(label: string): {
|
|
count: number;
|
|
total: number;
|
|
average: number;
|
|
min: number;
|
|
max: number;
|
|
median: number;
|
|
} | null {
|
|
const durations = this.measurements.get(label);
|
|
if (!durations || durations.length === 0) return null;
|
|
|
|
const sorted = [...durations].sort((a, b) => a - b);
|
|
const total = durations.reduce((sum, d) => sum + d, 0);
|
|
|
|
return {
|
|
count: durations.length,
|
|
total,
|
|
average: total / durations.length,
|
|
min: sorted[0],
|
|
max: sorted[sorted.length - 1],
|
|
median: sorted[Math.floor(sorted.length / 2)]
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clears all collected measurements.
|
|
*/
|
|
clear(): void {
|
|
this.measurements.clear();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test data generator for creating mock nodes, templates, and other test objects.
|
|
* Provides consistent test data with sensible defaults and easy customization.
|
|
*/
|
|
export class TestDataGenerator {
|
|
/**
|
|
* Generates a mock node object with default values and custom overrides.
|
|
*
|
|
* @param overrides - Properties to override in the generated node
|
|
* @returns Complete node object suitable for testing
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const node = TestDataGenerator.generateNode({
|
|
* displayName: 'Custom Node',
|
|
* isAITool: true
|
|
* });
|
|
* ```
|
|
*/
|
|
static generateNode(overrides: any = {}): any {
|
|
const nodeName = overrides.name || `testNode${Math.random().toString(36).substr(2, 9)}`;
|
|
return {
|
|
nodeType: overrides.nodeType || `n8n-nodes-base.${nodeName}`,
|
|
packageName: overrides.packageName || overrides.package || 'n8n-nodes-base',
|
|
displayName: overrides.displayName || 'Test Node',
|
|
description: overrides.description || 'A test node for integration testing',
|
|
category: overrides.category || 'automation',
|
|
developmentStyle: overrides.developmentStyle || overrides.style || 'programmatic',
|
|
isAITool: overrides.isAITool || false,
|
|
isTrigger: overrides.isTrigger || false,
|
|
isWebhook: overrides.isWebhook || false,
|
|
isVersioned: overrides.isVersioned !== undefined ? overrides.isVersioned : true,
|
|
version: overrides.version || '1',
|
|
documentation: overrides.documentation || null,
|
|
properties: overrides.properties || [],
|
|
operations: overrides.operations || [],
|
|
credentials: overrides.credentials || [],
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generates multiple nodes with sequential naming.
|
|
*
|
|
* @param count - Number of nodes to generate
|
|
* @param template - Common properties to apply to all nodes
|
|
* @returns Array of generated nodes
|
|
*/
|
|
static generateNodes(count: number, template: any = {}): any[] {
|
|
return Array.from({ length: count }, (_, i) =>
|
|
this.generateNode({
|
|
...template,
|
|
name: `testNode${i}`,
|
|
displayName: `Test Node ${i}`,
|
|
nodeType: `n8n-nodes-base.testNode${i}`
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generates a mock workflow template.
|
|
*
|
|
* @param overrides - Properties to override in the template
|
|
* @returns Template object suitable for testing
|
|
*/
|
|
static generateTemplate(overrides: any = {}): any {
|
|
return {
|
|
id: Math.floor(Math.random() * 100000),
|
|
name: `Test Workflow ${Math.random().toString(36).substr(2, 9)}`,
|
|
totalViews: Math.floor(Math.random() * 1000),
|
|
nodeTypes: ['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest'],
|
|
categories: [{ id: 1, name: 'automation' }],
|
|
description: 'A test workflow template',
|
|
workflowInfo: {
|
|
nodeCount: 5,
|
|
webhookCount: 1
|
|
},
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generates multiple workflow templates.
|
|
*
|
|
* @param count - Number of templates to generate
|
|
* @returns Array of template objects
|
|
*/
|
|
static generateTemplates(count: number): any[] {
|
|
return Array.from({ length: count }, () => this.generateTemplate());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs a function within a database transaction with automatic rollback on error.
|
|
* Useful for testing transactional behavior and ensuring test isolation.
|
|
*
|
|
* @param db - Database instance
|
|
* @param fn - Function to run within transaction
|
|
* @returns Promise resolving to function result
|
|
* @throws Rolls back transaction and rethrows any errors
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* await runInTransaction(db, () => {
|
|
* db.prepare('INSERT INTO nodes ...').run();
|
|
* db.prepare('UPDATE nodes ...').run();
|
|
* // If any operation fails, all are rolled back
|
|
* });
|
|
* ```
|
|
*/
|
|
export async function runInTransaction<T>(
|
|
db: Database.Database,
|
|
fn: () => T
|
|
): Promise<T> {
|
|
db.exec('BEGIN');
|
|
try {
|
|
const result = await fn();
|
|
db.exec('COMMIT');
|
|
return result;
|
|
} catch (error) {
|
|
db.exec('ROLLBACK');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Simulates concurrent database access using worker processes.
|
|
* Useful for testing database locking and concurrency handling.
|
|
*
|
|
* @param dbPath - Path to the database file
|
|
* @param workerCount - Number of concurrent workers to spawn
|
|
* @param operations - Number of operations each worker should perform
|
|
* @param workerScript - JavaScript code to execute in each worker
|
|
* @returns Results with success/failure counts and total duration
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const results = await simulateConcurrentAccess(
|
|
* dbPath,
|
|
* 10, // 10 workers
|
|
* 100, // 100 operations each
|
|
* `
|
|
* const db = require('better-sqlite3')(process.env.DB_PATH);
|
|
* for (let i = 0; i < process.env.OPERATIONS; i++) {
|
|
* db.prepare('INSERT INTO test VALUES (?)').run(i);
|
|
* }
|
|
* `
|
|
* );
|
|
* ```
|
|
*/
|
|
export async function simulateConcurrentAccess(
|
|
dbPath: string,
|
|
workerCount: number,
|
|
operations: number,
|
|
workerScript: string
|
|
): Promise<{ success: number; failed: number; duration: number }> {
|
|
const startTime = Date.now();
|
|
const results = { success: 0, failed: 0 };
|
|
|
|
// Create worker processes
|
|
const workers = Array.from({ length: workerCount }, (_, i) => {
|
|
return new Promise<void>((resolve) => {
|
|
try {
|
|
const output = execSync(
|
|
`node -e "${workerScript}"`,
|
|
{
|
|
env: {
|
|
...process.env,
|
|
DB_PATH: dbPath,
|
|
WORKER_ID: i.toString(),
|
|
OPERATIONS: operations.toString()
|
|
}
|
|
}
|
|
);
|
|
results.success++;
|
|
} catch (error) {
|
|
results.failed++;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
await Promise.all(workers);
|
|
|
|
return {
|
|
...results,
|
|
duration: Date.now() - startTime
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Performs comprehensive database integrity checks including foreign keys and schema.
|
|
*
|
|
* @param db - Database instance to check
|
|
* @returns Object with validation status and any error messages
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const integrity = checkDatabaseIntegrity(db);
|
|
* if (!integrity.isValid) {
|
|
* console.error('Database issues:', integrity.errors);
|
|
* }
|
|
* ```
|
|
*/
|
|
export function checkDatabaseIntegrity(db: Database.Database): {
|
|
isValid: boolean;
|
|
errors: string[];
|
|
} {
|
|
const errors: string[] = [];
|
|
|
|
try {
|
|
// Run integrity check
|
|
const result = db.prepare('PRAGMA integrity_check').all() as Array<{ integrity_check: string }>;
|
|
if (result.length !== 1 || result[0].integrity_check !== 'ok') {
|
|
errors.push('Database integrity check failed');
|
|
}
|
|
|
|
// Check foreign key constraints
|
|
const fkResult = db.prepare('PRAGMA foreign_key_check').all();
|
|
if (fkResult.length > 0) {
|
|
errors.push(`Foreign key violations: ${JSON.stringify(fkResult)}`);
|
|
}
|
|
|
|
// Check table existence
|
|
const tables = db.prepare(`
|
|
SELECT name FROM sqlite_master
|
|
WHERE type = 'table' AND name = 'nodes'
|
|
`).all();
|
|
|
|
if (tables.length === 0) {
|
|
errors.push('nodes table does not exist');
|
|
}
|
|
|
|
} catch (error: any) {
|
|
errors.push(`Integrity check error: ${error.message}`);
|
|
}
|
|
|
|
return {
|
|
isValid: errors.length === 0,
|
|
errors
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a DatabaseAdapter interface from a better-sqlite3 instance.
|
|
* This adapter provides a consistent interface for database operations across the codebase.
|
|
*
|
|
* @param db - better-sqlite3 database instance
|
|
* @returns DatabaseAdapter implementation
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const db = new Database(':memory:');
|
|
* const adapter = createTestDatabaseAdapter(db);
|
|
* const stmt = adapter.prepare('SELECT * FROM nodes WHERE type = ?');
|
|
* const nodes = stmt.all('webhook');
|
|
* ```
|
|
*/
|
|
export function createTestDatabaseAdapter(db: Database.Database): DatabaseAdapter {
|
|
return {
|
|
prepare: (sql: string) => {
|
|
const stmt = db.prepare(sql);
|
|
return {
|
|
run: (...params: any[]) => stmt.run(...params),
|
|
get: (...params: any[]) => stmt.get(...params),
|
|
all: (...params: any[]) => stmt.all(...params),
|
|
iterate: (...params: any[]) => stmt.iterate(...params),
|
|
pluck: function(enabled?: boolean) { stmt.pluck(enabled); return this; },
|
|
expand: function(enabled?: boolean) { stmt.expand?.(enabled); return this; },
|
|
raw: function(enabled?: boolean) { stmt.raw?.(enabled); return this; },
|
|
columns: () => stmt.columns?.() || [],
|
|
bind: function(...params: any[]) { stmt.bind(...params); return this; }
|
|
} as any;
|
|
},
|
|
exec: (sql: string) => db.exec(sql),
|
|
close: () => db.close(),
|
|
pragma: (key: string, value?: any) => db.pragma(key, value),
|
|
get inTransaction() { return db.inTransaction; },
|
|
transaction: <T>(fn: () => T) => db.transaction(fn)(),
|
|
checkFTS5Support: () => {
|
|
try {
|
|
db.exec('CREATE VIRTUAL TABLE test_fts5_check USING fts5(content)');
|
|
db.exec('DROP TABLE test_fts5_check');
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pre-configured mock nodes for common testing scenarios.
|
|
* These represent the most commonly used n8n nodes with realistic configurations.
|
|
*/
|
|
export const MOCK_NODES = {
|
|
webhook: {
|
|
nodeType: 'n8n-nodes-base.webhook',
|
|
packageName: 'n8n-nodes-base',
|
|
displayName: 'Webhook',
|
|
description: 'Starts the workflow when a webhook is called',
|
|
category: 'trigger',
|
|
developmentStyle: 'programmatic',
|
|
isAITool: false,
|
|
isTrigger: true,
|
|
isWebhook: true,
|
|
isVersioned: true,
|
|
version: '1',
|
|
documentation: 'Webhook documentation',
|
|
properties: [
|
|
{
|
|
displayName: 'HTTP Method',
|
|
name: 'httpMethod',
|
|
type: 'options',
|
|
options: [
|
|
{ name: 'GET', value: 'GET' },
|
|
{ name: 'POST', value: 'POST' }
|
|
],
|
|
default: 'GET'
|
|
}
|
|
],
|
|
operations: [],
|
|
credentials: []
|
|
},
|
|
httpRequest: {
|
|
nodeType: 'n8n-nodes-base.httpRequest',
|
|
packageName: 'n8n-nodes-base',
|
|
displayName: 'HTTP Request',
|
|
description: 'Makes an HTTP request and returns the response',
|
|
category: 'automation',
|
|
developmentStyle: 'programmatic',
|
|
isAITool: false,
|
|
isTrigger: false,
|
|
isWebhook: false,
|
|
isVersioned: true,
|
|
version: '1',
|
|
documentation: 'HTTP Request documentation',
|
|
properties: [
|
|
{
|
|
displayName: 'URL',
|
|
name: 'url',
|
|
type: 'string',
|
|
required: true,
|
|
default: ''
|
|
}
|
|
],
|
|
operations: [],
|
|
credentials: []
|
|
}
|
|
}; |