import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { CommunityNodeService, SyncResult, SyncOptions } from '@/community/community-node-service';
import { NodeRepository, CommunityNodeFields } from '@/database/node-repository';
import {
CommunityNodeFetcher,
StrapiCommunityNode,
NpmSearchResult,
} from '@/community/community-node-fetcher';
import { ParsedNode } from '@/parsers/node-parser';
// Mock the fetcher
vi.mock('@/community/community-node-fetcher', () => ({
CommunityNodeFetcher: vi.fn().mockImplementation(() => ({
fetchVerifiedNodes: vi.fn(),
fetchNpmPackages: vi.fn(),
})),
}));
// Mock logger
vi.mock('@/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
describe('CommunityNodeService', () => {
let service: CommunityNodeService;
let mockRepository: Partial<NodeRepository>;
let mockFetcher: {
fetchVerifiedNodes: ReturnType<typeof vi.fn>;
fetchNpmPackages: ReturnType<typeof vi.fn>;
};
// Sample test data
const mockStrapiNode: StrapiCommunityNode = {
id: 1,
attributes: {
name: 'TestNode',
displayName: 'Test Node',
description: 'A test community node',
packageName: 'n8n-nodes-test',
authorName: 'Test Author',
authorGithubUrl: 'https://github.com/testauthor',
npmVersion: '1.0.0',
numberOfDownloads: 1000,
numberOfStars: 50,
isOfficialNode: false,
isPublished: true,
nodeDescription: {
name: 'n8n-nodes-test.testNode',
displayName: 'Test Node',
description: 'A test node',
properties: [{ name: 'url', type: 'string' }],
credentials: [],
version: 1,
group: ['transform'],
},
nodeVersions: [],
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-02T00:00:00.000Z',
},
};
const mockNpmPackage: NpmSearchResult = {
package: {
name: 'n8n-nodes-npm-test',
version: '1.0.0',
description: 'A test npm community node',
keywords: ['n8n-community-node-package'],
date: '2024-01-01T00:00:00.000Z',
links: {
npm: 'https://www.npmjs.com/package/n8n-nodes-npm-test',
repository: 'https://github.com/test/n8n-nodes-npm-test',
},
author: { name: 'NPM Author' },
publisher: { username: 'npmauthor', email: 'npm@example.com' },
maintainers: [{ username: 'npmauthor', email: 'npm@example.com' }],
},
score: {
final: 0.8,
detail: {
quality: 0.9,
popularity: 0.7,
maintenance: 0.8,
},
},
searchScore: 1000,
};
beforeEach(() => {
vi.clearAllMocks();
// Create mock repository
mockRepository = {
saveNode: vi.fn(),
hasNodeByNpmPackage: vi.fn().mockReturnValue(false),
getCommunityNodes: vi.fn().mockReturnValue([]),
getCommunityStats: vi.fn().mockReturnValue({ total: 0, verified: 0, unverified: 0 }),
deleteCommunityNodes: vi.fn().mockReturnValue(0),
};
// Create mock fetcher instance
mockFetcher = {
fetchVerifiedNodes: vi.fn().mockResolvedValue([]),
fetchNpmPackages: vi.fn().mockResolvedValue([]),
};
// Override CommunityNodeFetcher to return our mock
(CommunityNodeFetcher as any).mockImplementation(() => mockFetcher);
service = new CommunityNodeService(mockRepository as NodeRepository, 'production');
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('syncCommunityNodes', () => {
it('should sync both verified and npm nodes by default', async () => {
mockFetcher.fetchVerifiedNodes.mockResolvedValue([mockStrapiNode]);
mockFetcher.fetchNpmPackages.mockResolvedValue([mockNpmPackage]);
const result = await service.syncCommunityNodes();
expect(result.verified.fetched).toBe(1);
expect(result.npm.fetched).toBe(1);
expect(result.duration).toBeGreaterThanOrEqual(0);
expect(mockFetcher.fetchVerifiedNodes).toHaveBeenCalled();
expect(mockFetcher.fetchNpmPackages).toHaveBeenCalled();
});
it('should only sync verified nodes when verifiedOnly is true', async () => {
mockFetcher.fetchVerifiedNodes.mockResolvedValue([mockStrapiNode]);
const result = await service.syncCommunityNodes({ verifiedOnly: true });
expect(result.verified.fetched).toBe(1);
expect(result.npm.fetched).toBe(0);
expect(mockFetcher.fetchVerifiedNodes).toHaveBeenCalled();
expect(mockFetcher.fetchNpmPackages).not.toHaveBeenCalled();
});
it('should respect npmLimit option', async () => {
mockFetcher.fetchVerifiedNodes.mockResolvedValue([]);
mockFetcher.fetchNpmPackages.mockResolvedValue([mockNpmPackage]);
await service.syncCommunityNodes({ npmLimit: 50 });
expect(mockFetcher.fetchNpmPackages).toHaveBeenCalledWith(
50,
undefined
);
});
it('should handle Strapi sync errors gracefully', async () => {
mockFetcher.fetchVerifiedNodes.mockRejectedValue(new Error('Strapi API error'));
mockFetcher.fetchNpmPackages.mockResolvedValue([mockNpmPackage]);
const result = await service.syncCommunityNodes();
expect(result.verified.errors).toContain('Strapi sync failed: Strapi API error');
expect(result.npm.fetched).toBe(1);
});
it('should handle npm sync errors gracefully', async () => {
mockFetcher.fetchVerifiedNodes.mockResolvedValue([mockStrapiNode]);
mockFetcher.fetchNpmPackages.mockRejectedValue(new Error('npm API error'));
const result = await service.syncCommunityNodes();
expect(result.verified.fetched).toBe(1);
expect(result.npm.errors).toContain('npm sync failed: npm API error');
});
it('should pass progress callback to fetcher', async () => {
const progressCallback = vi.fn();
mockFetcher.fetchVerifiedNodes.mockResolvedValue([mockStrapiNode]);
mockFetcher.fetchNpmPackages.mockResolvedValue([mockNpmPackage]);
await service.syncCommunityNodes({}, progressCallback);
// The progress callback is passed to fetchVerifiedNodes
expect(mockFetcher.fetchVerifiedNodes).toHaveBeenCalled();
const call = mockFetcher.fetchVerifiedNodes.mock.calls[0];
expect(typeof call[0]).toBe('function'); // Progress callback
});
it('should calculate duration correctly', async () => {
mockFetcher.fetchVerifiedNodes.mockImplementation(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
return [mockStrapiNode];
});
mockFetcher.fetchNpmPackages.mockResolvedValue([]);
const result = await service.syncCommunityNodes({ verifiedOnly: true });
expect(result.duration).toBeGreaterThanOrEqual(10);
});
});
describe('syncVerifiedNodes', () => {
it('should save verified nodes to repository', async () => {
mockFetcher.fetchVerifiedNodes.mockResolvedValue([mockStrapiNode]);
const result = await service.syncVerifiedNodes();
expect(result.fetched).toBe(1);
expect(result.saved).toBe(1);
expect(mockRepository.saveNode).toHaveBeenCalledTimes(1);
});
it('should skip existing nodes when skipExisting is true', async () => {
mockFetcher.fetchVerifiedNodes.mockResolvedValue([mockStrapiNode]);
(mockRepository.hasNodeByNpmPackage as any).mockReturnValue(true);
const result = await service.syncVerifiedNodes(undefined, true);
expect(result.fetched).toBe(1);
expect(result.saved).toBe(0);
expect(result.skipped).toBe(1);
expect(mockRepository.saveNode).not.toHaveBeenCalled();
});
it('should handle nodes without nodeDescription', async () => {
const nodeWithoutDesc = {
...mockStrapiNode,
attributes: { ...mockStrapiNode.attributes, nodeDescription: null },
};
mockFetcher.fetchVerifiedNodes.mockResolvedValue([nodeWithoutDesc]);
const result = await service.syncVerifiedNodes();
expect(result.fetched).toBe(1);
expect(result.saved).toBe(0);
expect(result.errors).toHaveLength(1);
});
it('should call progress callback during save', async () => {
mockFetcher.fetchVerifiedNodes.mockResolvedValue([mockStrapiNode]);
const progressCallback = vi.fn();
await service.syncVerifiedNodes(progressCallback);
expect(progressCallback).toHaveBeenCalledWith(
'Saving verified nodes',
1,
1
);
});
it('should handle empty response', async () => {
mockFetcher.fetchVerifiedNodes.mockResolvedValue([]);
const result = await service.syncVerifiedNodes();
expect(result.fetched).toBe(0);
expect(result.saved).toBe(0);
expect(mockRepository.saveNode).not.toHaveBeenCalled();
});
it('should handle save errors gracefully', async () => {
mockFetcher.fetchVerifiedNodes.mockResolvedValue([mockStrapiNode]);
(mockRepository.saveNode as any).mockImplementation(() => {
throw new Error('Database error');
});
const result = await service.syncVerifiedNodes();
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toContain('Error saving n8n-nodes-test');
});
});
describe('syncNpmNodes', () => {
it('should save npm packages to repository', async () => {
mockFetcher.fetchNpmPackages.mockResolvedValue([mockNpmPackage]);
const result = await service.syncNpmNodes();
expect(result.fetched).toBe(1);
expect(result.saved).toBe(1);
expect(mockRepository.saveNode).toHaveBeenCalledTimes(1);
});
it('should skip packages already synced from Strapi', async () => {
const verifiedPackage = {
nodeType: 'n8n-nodes-npm-test.NpmTest',
npmPackageName: 'n8n-nodes-npm-test',
isVerified: true,
};
(mockRepository.getCommunityNodes as any).mockReturnValue([verifiedPackage]);
mockFetcher.fetchNpmPackages.mockResolvedValue([mockNpmPackage]);
const result = await service.syncNpmNodes();
expect(result.fetched).toBe(1);
expect(result.saved).toBe(0);
expect(result.skipped).toBe(1);
});
it('should skip existing packages when skipExisting is true', async () => {
mockFetcher.fetchNpmPackages.mockResolvedValue([mockNpmPackage]);
(mockRepository.hasNodeByNpmPackage as any).mockReturnValue(true);
const result = await service.syncNpmNodes(100, undefined, true);
expect(result.skipped).toBe(1);
expect(result.saved).toBe(0);
});
it('should respect limit parameter', async () => {
mockFetcher.fetchNpmPackages.mockResolvedValue([]);
await service.syncNpmNodes(50);
expect(mockFetcher.fetchNpmPackages).toHaveBeenCalledWith(
50,
undefined
);
});
it('should handle empty response', async () => {
mockFetcher.fetchNpmPackages.mockResolvedValue([]);
const result = await service.syncNpmNodes();
expect(result.fetched).toBe(0);
expect(result.saved).toBe(0);
});
it('should handle save errors gracefully', async () => {
mockFetcher.fetchNpmPackages.mockResolvedValue([mockNpmPackage]);
(mockRepository.saveNode as any).mockImplementation(() => {
throw new Error('Database error');
});
const result = await service.syncNpmNodes();
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toContain('Error saving n8n-nodes-npm-test');
});
});
describe('strapiNodeToParsedNode (via syncVerifiedNodes)', () => {
it('should convert Strapi node to ParsedNode format', async () => {
mockFetcher.fetchVerifiedNodes.mockResolvedValue([mockStrapiNode]);
await service.syncVerifiedNodes();
expect(mockRepository.saveNode).toHaveBeenCalledWith(
expect.objectContaining({
nodeType: 'n8n-nodes-test.testNode',
packageName: 'n8n-nodes-test',
displayName: 'Test Node',
description: 'A test node',
isCommunity: true,
isVerified: true,
authorName: 'Test Author',
npmPackageName: 'n8n-nodes-test',
npmVersion: '1.0.0',
npmDownloads: 1000,
})
);
});
it('should transform preview node types to actual node types', async () => {
const previewNode = {
...mockStrapiNode,
attributes: {
...mockStrapiNode.attributes,
nodeDescription: {
...mockStrapiNode.attributes.nodeDescription,
name: 'n8n-nodes-preview-test.testNode',
},
},
};
mockFetcher.fetchVerifiedNodes.mockResolvedValue([previewNode]);
await service.syncVerifiedNodes();
expect(mockRepository.saveNode).toHaveBeenCalledWith(
expect.objectContaining({
nodeType: 'n8n-nodes-test.testNode',
})
);
});
it('should detect AI tools', async () => {
const aiNode = {
...mockStrapiNode,
attributes: {
...mockStrapiNode.attributes,
nodeDescription: {
...mockStrapiNode.attributes.nodeDescription,
usableAsTool: true,
},
},
};
mockFetcher.fetchVerifiedNodes.mockResolvedValue([aiNode]);
await service.syncVerifiedNodes();
expect(mockRepository.saveNode).toHaveBeenCalledWith(
expect.objectContaining({
isAITool: true,
})
);
});
it('should detect triggers', async () => {
const triggerNode = {
...mockStrapiNode,
attributes: {
...mockStrapiNode.attributes,
nodeDescription: {
...mockStrapiNode.attributes.nodeDescription,
group: ['trigger'],
},
},
};
mockFetcher.fetchVerifiedNodes.mockResolvedValue([triggerNode]);
await service.syncVerifiedNodes();
expect(mockRepository.saveNode).toHaveBeenCalledWith(
expect.objectContaining({
isTrigger: true,
})
);
});
it('should detect webhooks', async () => {
const webhookNode = {
...mockStrapiNode,
attributes: {
...mockStrapiNode.attributes,
nodeDescription: {
...mockStrapiNode.attributes.nodeDescription,
name: 'n8n-nodes-test.webhookHandler',
group: ['webhook'],
},
},
};
mockFetcher.fetchVerifiedNodes.mockResolvedValue([webhookNode]);
await service.syncVerifiedNodes();
expect(mockRepository.saveNode).toHaveBeenCalledWith(
expect.objectContaining({
isWebhook: true,
})
);
});
it('should extract operations from properties', async () => {
const nodeWithOperations = {
...mockStrapiNode,
attributes: {
...mockStrapiNode.attributes,
nodeDescription: {
...mockStrapiNode.attributes.nodeDescription,
properties: [
{
name: 'operation',
options: [
{ name: 'create', displayName: 'Create' },
{ name: 'read', displayName: 'Read' },
],
},
],
},
},
};
mockFetcher.fetchVerifiedNodes.mockResolvedValue([nodeWithOperations]);
await service.syncVerifiedNodes();
expect(mockRepository.saveNode).toHaveBeenCalledWith(
expect.objectContaining({
operations: [
{ name: 'create', displayName: 'Create' },
{ name: 'read', displayName: 'Read' },
],
})
);
});
it('should handle nodes with AI category in codex', async () => {
const aiCategoryNode = {
...mockStrapiNode,
attributes: {
...mockStrapiNode.attributes,
nodeDescription: {
...mockStrapiNode.attributes.nodeDescription,
codex: { categories: ['AI'] },
},
},
};
mockFetcher.fetchVerifiedNodes.mockResolvedValue([aiCategoryNode]);
await service.syncVerifiedNodes();
expect(mockRepository.saveNode).toHaveBeenCalledWith(
expect.objectContaining({
isAITool: true,
})
);
});
});
describe('npmPackageToParsedNode (via syncNpmNodes)', () => {
it('should convert npm package to ParsedNode format', async () => {
mockFetcher.fetchNpmPackages.mockResolvedValue([mockNpmPackage]);
await service.syncNpmNodes();
expect(mockRepository.saveNode).toHaveBeenCalledWith(
expect.objectContaining({
nodeType: 'n8n-nodes-npm-test.npmtest',
packageName: 'n8n-nodes-npm-test',
displayName: 'npmtest',
description: 'A test npm community node',
isCommunity: true,
isVerified: false,
authorName: 'NPM Author',
npmPackageName: 'n8n-nodes-npm-test',
npmVersion: '1.0.0',
})
);
});
it('should handle scoped packages', async () => {
const scopedPackage = {
...mockNpmPackage,
package: {
...mockNpmPackage.package,
name: '@myorg/n8n-nodes-custom',
},
};
mockFetcher.fetchNpmPackages.mockResolvedValue([scopedPackage]);
await service.syncNpmNodes();
expect(mockRepository.saveNode).toHaveBeenCalledWith(
expect.objectContaining({
displayName: 'custom',
})
);
});
it('should handle packages without author', async () => {
const packageWithoutAuthor = {
...mockNpmPackage,
package: {
...mockNpmPackage.package,
author: undefined,
},
};
mockFetcher.fetchNpmPackages.mockResolvedValue([packageWithoutAuthor]);
await service.syncNpmNodes();
expect(mockRepository.saveNode).toHaveBeenCalledWith(
expect.objectContaining({
authorName: 'npmauthor', // Falls back to publisher.username
})
);
});
it('should detect trigger packages', async () => {
const triggerPackage = {
...mockNpmPackage,
package: {
...mockNpmPackage.package,
name: 'n8n-nodes-trigger-test',
},
};
mockFetcher.fetchNpmPackages.mockResolvedValue([triggerPackage]);
await service.syncNpmNodes();
expect(mockRepository.saveNode).toHaveBeenCalledWith(
expect.objectContaining({
isTrigger: true,
})
);
});
it('should detect webhook packages', async () => {
const webhookPackage = {
...mockNpmPackage,
package: {
...mockNpmPackage.package,
name: 'n8n-nodes-webhook-handler',
},
};
mockFetcher.fetchNpmPackages.mockResolvedValue([webhookPackage]);
await service.syncNpmNodes();
expect(mockRepository.saveNode).toHaveBeenCalledWith(
expect.objectContaining({
isWebhook: true,
})
);
});
it('should calculate approximate downloads from popularity score', async () => {
const popularPackage = {
...mockNpmPackage,
score: {
...mockNpmPackage.score,
detail: {
...mockNpmPackage.score.detail,
popularity: 0.5,
},
},
};
mockFetcher.fetchNpmPackages.mockResolvedValue([popularPackage]);
await service.syncNpmNodes();
expect(mockRepository.saveNode).toHaveBeenCalledWith(
expect.objectContaining({
npmDownloads: 5000, // 0.5 * 10000
})
);
});
});
describe('getCommunityStats', () => {
it('should return community stats from repository', () => {
const mockStats = { total: 100, verified: 30, unverified: 70 };
(mockRepository.getCommunityStats as any).mockReturnValue(mockStats);
const result = service.getCommunityStats();
expect(result).toEqual(mockStats);
expect(mockRepository.getCommunityStats).toHaveBeenCalled();
});
});
describe('deleteCommunityNodes', () => {
it('should delete community nodes and return count', () => {
(mockRepository.deleteCommunityNodes as any).mockReturnValue(50);
const result = service.deleteCommunityNodes();
expect(result).toBe(50);
expect(mockRepository.deleteCommunityNodes).toHaveBeenCalled();
});
});
describe('edge cases', () => {
it('should handle nodes with empty properties', async () => {
const emptyPropsNode = {
...mockStrapiNode,
attributes: {
...mockStrapiNode.attributes,
nodeDescription: {
...mockStrapiNode.attributes.nodeDescription,
properties: [],
credentials: [],
},
},
};
mockFetcher.fetchVerifiedNodes.mockResolvedValue([emptyPropsNode]);
await service.syncVerifiedNodes();
expect(mockRepository.saveNode).toHaveBeenCalledWith(
expect.objectContaining({
properties: [],
credentials: [],
})
);
});
it('should handle nodes with multiple versions', async () => {
const versionedNode = {
...mockStrapiNode,
attributes: {
...mockStrapiNode.attributes,
nodeVersions: [{ version: 1 }, { version: 2 }],
},
};
mockFetcher.fetchVerifiedNodes.mockResolvedValue([versionedNode]);
await service.syncVerifiedNodes();
expect(mockRepository.saveNode).toHaveBeenCalledWith(
expect.objectContaining({
isVersioned: true,
})
);
});
it('should handle concurrent sync operations', async () => {
mockFetcher.fetchVerifiedNodes.mockImplementation(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
return [mockStrapiNode];
});
mockFetcher.fetchNpmPackages.mockImplementation(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
return [mockNpmPackage];
});
// Start two sync operations concurrently
const results = await Promise.all([
service.syncCommunityNodes({ verifiedOnly: true }),
service.syncCommunityNodes({ verifiedOnly: true }),
]);
expect(results).toHaveLength(2);
expect(results[0].verified.fetched).toBe(1);
expect(results[1].verified.fetched).toBe(1);
});
});
});