import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NodeRepository, CommunityNodeFields } from '@/database/node-repository';
import { DatabaseAdapter, PreparedStatement, RunResult } from '@/database/database-adapter';
import { ParsedNode } from '@/parsers/node-parser';
/**
* Integration tests for the community nodes feature.
*
* These tests verify the end-to-end flow of community node operations
* using a mock database adapter that simulates real database behavior.
*/
// Mock logger
vi.mock('@/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
/**
* In-memory database adapter for integration testing
*/
class InMemoryDatabaseAdapter implements DatabaseAdapter {
private nodes: Map<string, any> = new Map();
private nodesByNpmPackage: Map<string, any> = new Map();
prepare = vi.fn((sql: string) => new InMemoryPreparedStatement(sql, this));
exec = vi.fn();
close = vi.fn();
pragma = vi.fn();
transaction = vi.fn((fn: () => any) => fn());
checkFTS5Support = vi.fn(() => true);
inTransaction = false;
// Data access methods for the prepared statement
saveNode(node: any): void {
this.nodes.set(node.node_type, node);
if (node.npm_package_name) {
this.nodesByNpmPackage.set(node.npm_package_name, node);
}
}
getNode(nodeType: string): any {
return this.nodes.get(nodeType);
}
getNodeByNpmPackage(npmPackageName: string): any {
return this.nodesByNpmPackage.get(npmPackageName);
}
hasNodeByNpmPackage(npmPackageName: string): boolean {
return this.nodesByNpmPackage.has(npmPackageName);
}
getAllNodes(): any[] {
return Array.from(this.nodes.values());
}
getCommunityNodes(verified?: boolean): any[] {
const nodes = this.getAllNodes().filter((n) => n.is_community === 1);
if (verified !== undefined) {
return nodes.filter((n) => (n.is_verified === 1) === verified);
}
return nodes;
}
deleteCommunityNodes(): number {
const communityNodes = this.getCommunityNodes();
for (const node of communityNodes) {
this.nodes.delete(node.node_type);
if (node.npm_package_name) {
this.nodesByNpmPackage.delete(node.npm_package_name);
}
}
return communityNodes.length;
}
clear(): void {
this.nodes.clear();
this.nodesByNpmPackage.clear();
}
}
class InMemoryPreparedStatement implements PreparedStatement {
run = vi.fn((...params: any[]): RunResult => {
if (this.sql.includes('INSERT OR REPLACE INTO nodes')) {
const node = this.paramsToNode(params);
this.adapter.saveNode(node);
return { changes: 1, lastInsertRowid: 1 };
}
if (this.sql.includes('DELETE FROM nodes WHERE is_community = 1')) {
const deleted = this.adapter.deleteCommunityNodes();
return { changes: deleted, lastInsertRowid: 0 };
}
return { changes: 0, lastInsertRowid: 0 };
});
get = vi.fn((...params: any[]) => {
if (this.sql.includes('SELECT * FROM nodes WHERE node_type = ?')) {
return this.adapter.getNode(params[0]);
}
if (this.sql.includes('SELECT * FROM nodes WHERE npm_package_name = ?')) {
return this.adapter.getNodeByNpmPackage(params[0]);
}
if (this.sql.includes('SELECT 1 FROM nodes WHERE npm_package_name = ?')) {
return this.adapter.hasNodeByNpmPackage(params[0]) ? { '1': 1 } : undefined;
}
if (this.sql.includes('SELECT COUNT(*) as count FROM nodes WHERE is_community = 1') &&
!this.sql.includes('is_verified')) {
return { count: this.adapter.getCommunityNodes().length };
}
if (this.sql.includes('SELECT COUNT(*) as count FROM nodes WHERE is_community = 1 AND is_verified = 1')) {
return { count: this.adapter.getCommunityNodes(true).length };
}
return undefined;
});
all = vi.fn((...params: any[]) => {
if (this.sql.includes('SELECT * FROM nodes WHERE is_community = 1')) {
let nodes = this.adapter.getCommunityNodes();
if (this.sql.includes('AND is_verified = ?')) {
const isVerified = params[0] === 1;
nodes = nodes.filter((n: any) => (n.is_verified === 1) === isVerified);
}
if (this.sql.includes('LIMIT ?')) {
const limit = params[params.length - 1];
nodes = nodes.slice(0, limit);
}
return nodes;
}
if (this.sql.includes('SELECT * FROM nodes ORDER BY display_name')) {
return this.adapter.getAllNodes();
}
return [];
});
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 adapter: InMemoryDatabaseAdapter) {}
private paramsToNode(params: any[]): any {
return {
node_type: params[0],
package_name: params[1],
display_name: params[2],
description: params[3],
category: params[4],
development_style: params[5],
is_ai_tool: params[6],
is_trigger: params[7],
is_webhook: params[8],
is_versioned: params[9],
is_tool_variant: params[10],
tool_variant_of: params[11],
has_tool_variant: params[12],
version: params[13],
documentation: params[14],
properties_schema: params[15],
operations: params[16],
credentials_required: params[17],
outputs: params[18],
output_names: params[19],
is_community: params[20],
is_verified: params[21],
author_name: params[22],
author_github_url: params[23],
npm_package_name: params[24],
npm_version: params[25],
npm_downloads: params[26],
community_fetched_at: params[27],
};
}
}
describe('Community Nodes Integration', () => {
let adapter: InMemoryDatabaseAdapter;
let repository: NodeRepository;
// Sample nodes for testing
const verifiedCommunityNode: ParsedNode & CommunityNodeFields = {
nodeType: 'n8n-nodes-verified.testNode',
packageName: 'n8n-nodes-verified',
displayName: 'Verified Test Node',
description: 'A verified community node for testing',
category: 'Community',
style: 'declarative',
properties: [{ name: 'url', type: 'string', displayName: 'URL' }],
credentials: [],
operations: [{ name: 'execute', displayName: 'Execute' }],
isAITool: false,
isTrigger: false,
isWebhook: false,
isVersioned: false,
version: '1.0.0',
isCommunity: true,
isVerified: true,
authorName: 'Verified Author',
authorGithubUrl: 'https://github.com/verified',
npmPackageName: 'n8n-nodes-verified',
npmVersion: '1.0.0',
npmDownloads: 5000,
communityFetchedAt: new Date().toISOString(),
};
const unverifiedCommunityNode: ParsedNode & CommunityNodeFields = {
nodeType: 'n8n-nodes-unverified.testNode',
packageName: 'n8n-nodes-unverified',
displayName: 'Unverified Test Node',
description: 'An unverified community node for testing',
category: 'Community',
style: 'declarative',
properties: [],
credentials: [],
operations: [],
isAITool: false,
isTrigger: true,
isWebhook: false,
isVersioned: false,
version: '0.5.0',
isCommunity: true,
isVerified: false,
authorName: 'Community Author',
npmPackageName: 'n8n-nodes-unverified',
npmVersion: '0.5.0',
npmDownloads: 1000,
communityFetchedAt: new Date().toISOString(),
};
const coreNode: ParsedNode = {
nodeType: 'nodes-base.httpRequest',
packageName: 'n8n-nodes-base',
displayName: 'HTTP Request',
description: 'Makes HTTP requests',
category: 'Core',
style: 'declarative',
properties: [{ name: 'url', type: 'string', displayName: 'URL' }],
credentials: [],
operations: [],
isAITool: false,
isTrigger: false,
isWebhook: false,
isVersioned: true,
version: '4.0',
};
beforeEach(() => {
vi.clearAllMocks();
adapter = new InMemoryDatabaseAdapter();
repository = new NodeRepository(adapter);
});
afterEach(() => {
adapter.clear();
});
describe('Full sync workflow', () => {
it('should save and retrieve community nodes correctly', () => {
// Save nodes
repository.saveNode(verifiedCommunityNode);
repository.saveNode(unverifiedCommunityNode);
repository.saveNode(coreNode);
// Verify community nodes
const communityNodes = repository.getCommunityNodes();
expect(communityNodes).toHaveLength(2);
// Verify verified filter
const verifiedNodes = repository.getCommunityNodes({ verified: true });
expect(verifiedNodes).toHaveLength(1);
expect(verifiedNodes[0].displayName).toBe('Verified Test Node');
// Verify unverified filter
const unverifiedNodes = repository.getCommunityNodes({ verified: false });
expect(unverifiedNodes).toHaveLength(1);
expect(unverifiedNodes[0].displayName).toBe('Unverified Test Node');
});
it('should correctly track community stats', () => {
repository.saveNode(verifiedCommunityNode);
repository.saveNode(unverifiedCommunityNode);
repository.saveNode(coreNode);
const stats = repository.getCommunityStats();
expect(stats.total).toBe(2);
expect(stats.verified).toBe(1);
expect(stats.unverified).toBe(1);
});
it('should check npm package existence correctly', () => {
repository.saveNode(verifiedCommunityNode);
expect(repository.hasNodeByNpmPackage('n8n-nodes-verified')).toBe(true);
expect(repository.hasNodeByNpmPackage('n8n-nodes-nonexistent')).toBe(false);
});
it('should delete only community nodes', () => {
repository.saveNode(verifiedCommunityNode);
repository.saveNode(unverifiedCommunityNode);
repository.saveNode(coreNode);
const deleted = repository.deleteCommunityNodes();
expect(deleted).toBe(2);
expect(repository.getCommunityNodes()).toHaveLength(0);
// Core node should still exist
expect(adapter.getNode('nodes-base.httpRequest')).toBeDefined();
});
});
describe('Node update workflow', () => {
it('should update existing community node', () => {
repository.saveNode(verifiedCommunityNode);
// Update the node
const updatedNode = {
...verifiedCommunityNode,
displayName: 'Updated Verified Node',
npmVersion: '1.1.0',
npmDownloads: 6000,
};
repository.saveNode(updatedNode);
const retrieved = repository.getNodeByNpmPackage('n8n-nodes-verified');
expect(retrieved).toBeDefined();
// Note: The actual update verification depends on parseNodeRow implementation
});
it('should handle transition from unverified to verified', () => {
repository.saveNode(unverifiedCommunityNode);
const nowVerified = {
...unverifiedCommunityNode,
isVerified: true,
};
repository.saveNode(nowVerified);
const stats = repository.getCommunityStats();
expect(stats.verified).toBe(1);
expect(stats.unverified).toBe(0);
});
});
describe('Edge cases', () => {
it('should handle empty database', () => {
expect(repository.getCommunityNodes()).toHaveLength(0);
expect(repository.getCommunityStats()).toEqual({
total: 0,
verified: 0,
unverified: 0,
});
expect(repository.hasNodeByNpmPackage('any-package')).toBe(false);
expect(repository.deleteCommunityNodes()).toBe(0);
});
it('should handle node with minimal fields', () => {
const minimalNode: ParsedNode & CommunityNodeFields = {
nodeType: 'n8n-nodes-minimal.node',
packageName: 'n8n-nodes-minimal',
displayName: 'Minimal Node',
description: 'Minimal',
category: 'Community',
style: 'declarative',
properties: [],
credentials: [],
operations: [],
isAITool: false,
isTrigger: false,
isWebhook: false,
isVersioned: false,
version: '1.0.0',
isCommunity: true,
isVerified: false,
npmPackageName: 'n8n-nodes-minimal',
};
repository.saveNode(minimalNode);
expect(repository.hasNodeByNpmPackage('n8n-nodes-minimal')).toBe(true);
expect(repository.getCommunityStats().total).toBe(1);
});
it('should handle multiple nodes from same package', () => {
const node1 = { ...verifiedCommunityNode };
const node2 = {
...verifiedCommunityNode,
nodeType: 'n8n-nodes-verified.anotherNode',
displayName: 'Another Node',
};
repository.saveNode(node1);
repository.saveNode(node2);
// Both should exist
expect(adapter.getNode('n8n-nodes-verified.testNode')).toBeDefined();
expect(adapter.getNode('n8n-nodes-verified.anotherNode')).toBeDefined();
});
it('should handle limit correctly', () => {
// Save multiple nodes
for (let i = 0; i < 10; i++) {
const node = {
...verifiedCommunityNode,
nodeType: `n8n-nodes-test-${i}.node`,
npmPackageName: `n8n-nodes-test-${i}`,
};
repository.saveNode(node);
}
const limited = repository.getCommunityNodes({ limit: 5 });
expect(limited).toHaveLength(5);
});
});
describe('Concurrent operations', () => {
it('should handle rapid consecutive saves', () => {
const nodes = Array(50)
.fill(null)
.map((_, i) => ({
...verifiedCommunityNode,
nodeType: `n8n-nodes-rapid-${i}.node`,
npmPackageName: `n8n-nodes-rapid-${i}`,
}));
nodes.forEach((node) => repository.saveNode(node));
expect(repository.getCommunityStats().total).toBe(50);
});
it('should handle save followed by immediate delete', () => {
repository.saveNode(verifiedCommunityNode);
expect(repository.getCommunityStats().total).toBe(1);
repository.deleteCommunityNodes();
expect(repository.getCommunityStats().total).toBe(0);
repository.saveNode(verifiedCommunityNode);
expect(repository.getCommunityStats().total).toBe(1);
});
});
});