template-repository-core.test.ts•16.2 kB
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TemplateRepository, StoredTemplate } from '../../../src/templates/template-repository';
import { DatabaseAdapter, PreparedStatement, RunResult } from '../../../src/database/database-adapter';
import { TemplateWorkflow, TemplateDetail } from '../../../src/templates/template-fetcher';
// Mock logger
vi.mock('../../../src/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}
}));
// Mock template sanitizer
vi.mock('../../../src/utils/template-sanitizer', () => {
class MockTemplateSanitizer {
sanitizeWorkflow = vi.fn((workflow) => ({ sanitized: workflow, wasModified: false }));
detectTokens = vi.fn(() => []);
}
return {
TemplateSanitizer: MockTemplateSanitizer
};
});
// Create mock database adapter
class MockDatabaseAdapter implements DatabaseAdapter {
private statements = new Map<string, MockPreparedStatement>();
private mockData = new Map<string, any>();
private _fts5Support = true;
prepare = vi.fn((sql: string) => {
if (!this.statements.has(sql)) {
this.statements.set(sql, new MockPreparedStatement(sql, this.mockData));
}
return this.statements.get(sql)!;
});
exec = vi.fn();
close = vi.fn();
pragma = vi.fn();
transaction = vi.fn((fn: () => any) => fn());
checkFTS5Support = vi.fn(() => this._fts5Support);
inTransaction = false;
// Test helpers
_setFTS5Support(supported: boolean) {
this._fts5Support = supported;
}
_setMockData(key: string, value: any) {
this.mockData.set(key, value);
}
_getStatement(sql: string) {
return this.statements.get(sql);
}
}
class MockPreparedStatement implements PreparedStatement {
run = vi.fn((...params: any[]): RunResult => ({ changes: 1, lastInsertRowid: 1 }));
get = vi.fn();
all = vi.fn(() => []);
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 mockData: Map<string, any>) {
// Configure based on SQL patterns
if (sql.includes('SELECT * FROM templates WHERE id = ?')) {
this.get = vi.fn((id: number) => this.mockData.get(`template:${id}`));
}
if (sql.includes('SELECT * FROM templates') && sql.includes('LIMIT')) {
this.all = vi.fn(() => this.mockData.get('all_templates') || []);
}
if (sql.includes('templates_fts')) {
this.all = vi.fn(() => this.mockData.get('fts_results') || []);
}
if (sql.includes('WHERE name LIKE')) {
this.all = vi.fn(() => this.mockData.get('like_results') || []);
}
if (sql.includes('COUNT(*) as count')) {
this.get = vi.fn(() => ({ count: this.mockData.get('template_count') || 0 }));
}
if (sql.includes('AVG(views)')) {
this.get = vi.fn(() => ({ avg: this.mockData.get('avg_views') || 0 }));
}
if (sql.includes('sqlite_master')) {
this.get = vi.fn(() => this.mockData.get('fts_table_exists') ? { name: 'templates_fts' } : undefined);
}
}
}
describe('TemplateRepository - Core Functionality', () => {
let repository: TemplateRepository;
let mockAdapter: MockDatabaseAdapter;
beforeEach(() => {
vi.clearAllMocks();
mockAdapter = new MockDatabaseAdapter();
mockAdapter._setMockData('fts_table_exists', false); // Default to creating FTS
repository = new TemplateRepository(mockAdapter);
});
describe('FTS5 initialization', () => {
it('should initialize FTS5 when supported', () => {
expect(mockAdapter.checkFTS5Support).toHaveBeenCalled();
expect(mockAdapter.exec).toHaveBeenCalledWith(expect.stringContaining('CREATE VIRTUAL TABLE'));
});
it('should skip FTS5 when not supported', () => {
mockAdapter._setFTS5Support(false);
mockAdapter.exec.mockClear();
const newRepo = new TemplateRepository(mockAdapter);
expect(mockAdapter.exec).not.toHaveBeenCalledWith(expect.stringContaining('CREATE VIRTUAL TABLE'));
});
});
describe('saveTemplate', () => {
it('should save a template with proper JSON serialization', () => {
const workflow: TemplateWorkflow = {
id: 123,
name: 'Test Workflow',
description: 'A test workflow',
user: {
id: 1,
name: 'John Doe',
username: 'johndoe',
verified: true
},
nodes: [
{ id: 1, name: 'n8n-nodes-base.httpRequest', icon: 'fa:globe' },
{ id: 2, name: 'n8n-nodes-base.slack', icon: 'fa:slack' }
],
totalViews: 1000,
createdAt: '2024-01-01T00:00:00Z'
};
const detail: TemplateDetail = {
id: 123,
name: 'Test Workflow',
description: 'A test workflow',
views: 1000,
createdAt: '2024-01-01T00:00:00Z',
workflow: {
nodes: [
{ type: 'n8n-nodes-base.httpRequest', name: 'HTTP Request', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 },
{ type: 'n8n-nodes-base.slack', name: 'Slack', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 }
],
connections: {},
settings: {}
}
};
const categories = ['automation', 'integration'];
repository.saveTemplate(workflow, detail, categories);
const stmt = mockAdapter._getStatement(mockAdapter.prepare.mock.calls.find(
call => call[0].includes('INSERT OR REPLACE INTO templates')
)?.[0] || '');
// The implementation now uses gzip compression, so we just verify the call happened
expect(stmt?.run).toHaveBeenCalledWith(
123, // id
123, // workflow_id
'Test Workflow',
'A test workflow',
'John Doe',
'johndoe',
1, // verified
JSON.stringify(['n8n-nodes-base.httpRequest', 'n8n-nodes-base.slack']),
expect.any(String), // compressed workflow JSON
JSON.stringify(['automation', 'integration']),
1000, // views
'2024-01-01T00:00:00Z',
'2024-01-01T00:00:00Z',
'https://n8n.io/workflows/123'
);
});
});
describe('getTemplate', () => {
it('should retrieve a specific template by ID', () => {
const mockTemplate: StoredTemplate = {
id: 123,
workflow_id: 123,
name: 'Test Template',
description: 'Description',
author_name: 'Author',
author_username: 'author',
author_verified: 1,
nodes_used: '[]',
workflow_json: '{}',
categories: '[]',
views: 500,
created_at: '2024-01-01',
updated_at: '2024-01-01',
url: 'https://n8n.io/workflows/123',
scraped_at: '2024-01-01'
};
mockAdapter._setMockData('template:123', mockTemplate);
const result = repository.getTemplate(123);
expect(result).toEqual(mockTemplate);
});
it('should return null for non-existent template', () => {
const result = repository.getTemplate(999);
expect(result).toBeNull();
});
});
describe('searchTemplates', () => {
it('should use FTS5 search when available', () => {
const ftsResults: StoredTemplate[] = [{
id: 1,
workflow_id: 1,
name: 'Chatbot Workflow',
description: 'AI chatbot',
author_name: 'Author',
author_username: 'author',
author_verified: 0,
nodes_used: '[]',
workflow_json: '{}',
categories: '[]',
views: 100,
created_at: '2024-01-01',
updated_at: '2024-01-01',
url: 'https://n8n.io/workflows/1',
scraped_at: '2024-01-01'
}];
mockAdapter._setMockData('fts_results', ftsResults);
const results = repository.searchTemplates('chatbot', 10);
expect(results).toEqual(ftsResults);
});
it('should fall back to LIKE search when FTS5 is not supported', () => {
mockAdapter._setFTS5Support(false);
const newRepo = new TemplateRepository(mockAdapter);
const likeResults: StoredTemplate[] = [{
id: 3,
workflow_id: 3,
name: 'LIKE only',
description: 'No FTS5',
author_name: 'Author',
author_username: 'author',
author_verified: 0,
nodes_used: '[]',
workflow_json: '{}',
categories: '[]',
views: 25,
created_at: '2024-01-01',
updated_at: '2024-01-01',
url: 'https://n8n.io/workflows/3',
scraped_at: '2024-01-01'
}];
mockAdapter._setMockData('like_results', likeResults);
const results = newRepo.searchTemplates('test', 20);
expect(results).toEqual(likeResults);
});
});
describe('getTemplatesByNodes', () => {
it('should find templates using specific node types', () => {
const mockTemplates: StoredTemplate[] = [{
id: 1,
workflow_id: 1,
name: 'HTTP Workflow',
description: 'Uses HTTP',
author_name: 'Author',
author_username: 'author',
author_verified: 1,
nodes_used: '["n8n-nodes-base.httpRequest"]',
workflow_json: '{}',
categories: '[]',
views: 100,
created_at: '2024-01-01',
updated_at: '2024-01-01',
url: 'https://n8n.io/workflows/1',
scraped_at: '2024-01-01'
}];
// Set up the mock to return our templates
const stmt = new MockPreparedStatement('', new Map());
stmt.all = vi.fn(() => mockTemplates);
mockAdapter.prepare = vi.fn(() => stmt);
const results = repository.getTemplatesByNodes(['n8n-nodes-base.httpRequest'], 5);
expect(stmt.all).toHaveBeenCalledWith('%"n8n-nodes-base.httpRequest"%', 5, 0);
expect(results).toEqual(mockTemplates);
});
});
describe('getTemplatesForTask', () => {
it('should return templates for known tasks', () => {
const aiTemplates: StoredTemplate[] = [{
id: 1,
workflow_id: 1,
name: 'AI Workflow',
description: 'Uses OpenAI',
author_name: 'Author',
author_username: 'author',
author_verified: 1,
nodes_used: '["@n8n/n8n-nodes-langchain.openAi"]',
workflow_json: '{}',
categories: '["ai"]',
views: 1000,
created_at: '2024-01-01',
updated_at: '2024-01-01',
url: 'https://n8n.io/workflows/1',
scraped_at: '2024-01-01'
}];
const stmt = new MockPreparedStatement('', new Map());
stmt.all = vi.fn(() => aiTemplates);
mockAdapter.prepare = vi.fn(() => stmt);
const results = repository.getTemplatesForTask('ai_automation');
expect(results).toEqual(aiTemplates);
});
it('should return empty array for unknown task', () => {
const results = repository.getTemplatesForTask('unknown_task');
expect(results).toEqual([]);
});
});
describe('template statistics', () => {
it('should get template count', () => {
mockAdapter._setMockData('template_count', 42);
const count = repository.getTemplateCount();
expect(count).toBe(42);
});
it('should get template statistics', () => {
mockAdapter._setMockData('template_count', 100);
mockAdapter._setMockData('avg_views', 250.5);
const topTemplates = [
{ nodes_used: '["n8n-nodes-base.httpRequest", "n8n-nodes-base.slack"]' },
{ nodes_used: '["n8n-nodes-base.httpRequest", "n8n-nodes-base.code"]' },
{ nodes_used: '["n8n-nodes-base.slack"]' }
];
const stmt = new MockPreparedStatement('', new Map());
stmt.all = vi.fn(() => topTemplates);
mockAdapter.prepare = vi.fn((sql) => {
if (sql.includes('ORDER BY views DESC')) {
return stmt;
}
return new MockPreparedStatement(sql, mockAdapter['mockData']);
});
const stats = repository.getTemplateStats();
expect(stats.totalTemplates).toBe(100);
expect(stats.averageViews).toBe(251);
expect(stats.topUsedNodes).toContainEqual({ node: 'n8n-nodes-base.httpRequest', count: 2 });
});
});
describe('pagination count methods', () => {
it('should get node templates count', () => {
mockAdapter._setMockData('node_templates_count', 15);
const stmt = new MockPreparedStatement('', new Map());
stmt.get = vi.fn(() => ({ count: 15 }));
mockAdapter.prepare = vi.fn(() => stmt);
const count = repository.getNodeTemplatesCount(['n8n-nodes-base.webhook']);
expect(count).toBe(15);
expect(stmt.get).toHaveBeenCalledWith('%"n8n-nodes-base.webhook"%');
});
it('should get search count', () => {
const stmt = new MockPreparedStatement('', new Map());
stmt.get = vi.fn(() => ({ count: 8 }));
mockAdapter.prepare = vi.fn(() => stmt);
const count = repository.getSearchCount('webhook');
expect(count).toBe(8);
});
it('should get task templates count', () => {
const stmt = new MockPreparedStatement('', new Map());
stmt.get = vi.fn(() => ({ count: 12 }));
mockAdapter.prepare = vi.fn(() => stmt);
const count = repository.getTaskTemplatesCount('ai_automation');
expect(count).toBe(12);
});
it('should handle pagination in getAllTemplates', () => {
const mockTemplates = [
{ id: 1, name: 'Template 1' },
{ id: 2, name: 'Template 2' }
];
const stmt = new MockPreparedStatement('', new Map());
stmt.all = vi.fn(() => mockTemplates);
mockAdapter.prepare = vi.fn(() => stmt);
const results = repository.getAllTemplates(10, 5, 'name');
expect(results).toEqual(mockTemplates);
expect(stmt.all).toHaveBeenCalledWith(10, 5);
});
it('should handle pagination in getTemplatesByNodes', () => {
const mockTemplates = [
{ id: 1, nodes_used: '["n8n-nodes-base.webhook"]' }
];
const stmt = new MockPreparedStatement('', new Map());
stmt.all = vi.fn(() => mockTemplates);
mockAdapter.prepare = vi.fn(() => stmt);
const results = repository.getTemplatesByNodes(['n8n-nodes-base.webhook'], 5, 10);
expect(results).toEqual(mockTemplates);
expect(stmt.all).toHaveBeenCalledWith('%"n8n-nodes-base.webhook"%', 5, 10);
});
it('should handle pagination in searchTemplates', () => {
const mockTemplates = [
{ id: 1, name: 'Search Result 1' }
];
mockAdapter._setMockData('fts_results', mockTemplates);
const stmt = new MockPreparedStatement('', new Map());
stmt.all = vi.fn(() => mockTemplates);
mockAdapter.prepare = vi.fn(() => stmt);
const results = repository.searchTemplates('webhook', 20, 40);
expect(results).toEqual(mockTemplates);
});
it('should handle pagination in getTemplatesForTask', () => {
const mockTemplates = [
{ id: 1, categories: '["ai"]' }
];
const stmt = new MockPreparedStatement('', new Map());
stmt.all = vi.fn(() => mockTemplates);
mockAdapter.prepare = vi.fn(() => stmt);
const results = repository.getTemplatesForTask('ai_automation', 15, 30);
expect(results).toEqual(mockTemplates);
});
});
describe('maintenance operations', () => {
it('should clear all templates', () => {
repository.clearTemplates();
expect(mockAdapter.exec).toHaveBeenCalledWith('DELETE FROM templates');
});
it('should rebuild FTS5 index when supported', () => {
repository.rebuildTemplateFTS();
expect(mockAdapter.exec).toHaveBeenCalledWith('DELETE FROM templates_fts');
expect(mockAdapter.exec).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO templates_fts')
);
});
});
});