From 3b767c798c1db25c09253a747a6ee915ff083b13 Mon Sep 17 00:00:00 2001 From: czlonkowski <56956555+czlonkowski@users.noreply.github.com> Date: Sun, 14 Sep 2025 15:42:35 +0200 Subject: [PATCH] fix: update tests for template compression, pagination, and quality filtering - Fix parameter validation tests to expect mode parameter in getTemplate calls - Update database utils tests to use totalViews > 10 for quality filter - Add comprehensive tests for template service functionality - Fix integration tests for new pagination parameters All CI tests now passing after template system enhancements --- .../integration/database-integration.test.ts | 3 +- .../database/template-repository.test.ts | 185 +++++- .../database/template-repository-core.test.ts | 108 +++- tests/unit/mcp/parameter-validation.test.ts | 4 +- tests/unit/mcp/template-handlers.test.ts | 481 +++++++++++++++ tests/unit/mcp/tools.test.ts | 81 ++- tests/unit/services/template-service.test.ts | 577 ++++++++++++++++++ tests/utils/database-utils.ts | 2 +- 8 files changed, 1425 insertions(+), 16 deletions(-) create mode 100644 tests/unit/mcp/template-handlers.test.ts create mode 100644 tests/unit/services/template-service.test.ts diff --git a/tests/integration/database-integration.test.ts b/tests/integration/database-integration.test.ts index a52af1e..26ae49c 100644 --- a/tests/integration/database-integration.test.ts +++ b/tests/integration/database-integration.test.ts @@ -163,7 +163,8 @@ describe('Database Integration Tests', () => { expect(template!.name).toBe('AI Content Generator'); // Parse workflow JSON - const workflow = JSON.parse(template!.workflow_json); + expect(template!.workflow_json).toBeTruthy(); + const workflow = JSON.parse(template!.workflow_json!); expect(workflow.nodes).toHaveLength(3); expect(workflow.nodes[0].name).toBe('Webhook'); expect(workflow.nodes[1].name).toBe('OpenAI'); diff --git a/tests/integration/database/template-repository.test.ts b/tests/integration/database/template-repository.test.ts index 8ebf79c..bfac754 100644 --- a/tests/integration/database/template-repository.test.ts +++ b/tests/integration/database/template-repository.test.ts @@ -131,7 +131,8 @@ describe('TemplateRepository Integration Tests', () => { const saved = repository.getTemplate(template.id); expect(saved).toBeTruthy(); - const workflowJson = JSON.parse(saved!.workflow_json); + expect(saved!.workflow_json).toBeTruthy(); + const workflowJson = JSON.parse(saved!.workflow_json!); expect(workflowJson.pinData).toBeUndefined(); }); }); @@ -239,6 +240,32 @@ describe('TemplateRepository Integration Tests', () => { const results = repository.searchTemplates('special'); expect(results.length).toBeGreaterThan(0); }); + + it('should support pagination in search results', () => { + for (let i = 1; i <= 15; i++) { + const template = createTemplateWorkflow({ + id: i, + name: `Search Template ${i}`, + description: 'Common search term' + }); + const detail = createTemplateDetail({ id: i }); + repository.saveTemplate(template, detail); + } + + const page1 = repository.searchTemplates('search', 5, 0); + expect(page1).toHaveLength(5); + + const page2 = repository.searchTemplates('search', 5, 5); + expect(page2).toHaveLength(5); + + const page3 = repository.searchTemplates('search', 5, 10); + expect(page3).toHaveLength(5); + + // Should be different templates on each page + const page1Ids = page1.map(t => t.id); + const page2Ids = page2.map(t => t.id); + expect(page1Ids.filter(id => page2Ids.includes(id))).toHaveLength(0); + }); }); describe('getTemplatesByNodeTypes', () => { @@ -319,6 +346,17 @@ describe('TemplateRepository Integration Tests', () => { const results = repository.getTemplatesByNodes(['n8n-nodes-base.webhook'], 1); expect(results).toHaveLength(1); }); + + it('should support pagination with offset', () => { + const results1 = repository.getTemplatesByNodes(['n8n-nodes-base.webhook'], 1, 0); + expect(results1).toHaveLength(1); + + const results2 = repository.getTemplatesByNodes(['n8n-nodes-base.webhook'], 1, 1); + expect(results2).toHaveLength(1); + + // Results should be different + expect(results1[0].id).not.toBe(results2[0].id); + }); }); describe('getAllTemplates', () => { @@ -338,6 +376,51 @@ describe('TemplateRepository Integration Tests', () => { expect(templates).toHaveLength(10); }); + it('should support pagination with offset', () => { + for (let i = 1; i <= 15; i++) { + const template = createTemplateWorkflow({ id: i }); + const detail = createTemplateDetail({ id: i }); + repository.saveTemplate(template, detail); + } + + const page1 = repository.getAllTemplates(5, 0); + expect(page1).toHaveLength(5); + + const page2 = repository.getAllTemplates(5, 5); + expect(page2).toHaveLength(5); + + const page3 = repository.getAllTemplates(5, 10); + expect(page3).toHaveLength(5); + + // Should be different templates on each page + const page1Ids = page1.map(t => t.id); + const page2Ids = page2.map(t => t.id); + const page3Ids = page3.map(t => t.id); + + expect(page1Ids.filter(id => page2Ids.includes(id))).toHaveLength(0); + expect(page2Ids.filter(id => page3Ids.includes(id))).toHaveLength(0); + }); + + it('should support different sort orders', () => { + const template1 = createTemplateWorkflow({ id: 1, name: 'Alpha Template', totalViews: 50 }); + const detail1 = createTemplateDetail({ id: 1 }); + repository.saveTemplate(template1, detail1); + + const template2 = createTemplateWorkflow({ id: 2, name: 'Beta Template', totalViews: 100 }); + const detail2 = createTemplateDetail({ id: 2 }); + repository.saveTemplate(template2, detail2); + + // Sort by views (default) - highest first + const byViews = repository.getAllTemplates(10, 0, 'views'); + expect(byViews[0].name).toBe('Beta Template'); + expect(byViews[1].name).toBe('Alpha Template'); + + // Sort by name - alphabetical + const byName = repository.getAllTemplates(10, 0, 'name'); + expect(byName[0].name).toBe('Alpha Template'); + expect(byName[1].name).toBe('Beta Template'); + }); + it('should order templates by views and created_at descending', () => { // Save templates with different views to ensure predictable ordering const template1 = createTemplateWorkflow({ id: 1, name: 'First', totalViews: 50 }); @@ -365,7 +448,7 @@ describe('TemplateRepository Integration Tests', () => { const saved = repository.getTemplate(1); expect(saved).toBeTruthy(); expect(saved?.workflow_json).toBeTruthy(); - const workflow = JSON.parse(saved!.workflow_json); + const workflow = JSON.parse(saved!.workflow_json!); expect(workflow.nodes).toHaveLength(detail.workflow.nodes.length); }); }); @@ -462,6 +545,104 @@ describe('TemplateRepository Integration Tests', () => { expect(results).toHaveLength(50); }); }); + + describe('New pagination count methods', () => { + beforeEach(() => { + // Set up test data + for (let i = 1; i <= 25; i++) { + const template = createTemplateWorkflow({ + id: i, + name: `Template ${i}`, + description: i <= 10 ? 'webhook automation' : 'data processing' + }); + const detail = createTemplateDetail({ + id: i, + workflow: { + nodes: i <= 15 ? [ + { id: 'node1', type: 'n8n-nodes-base.webhook', name: 'Webhook', position: [0, 0], parameters: {}, typeVersion: 1 } + ] : [ + { id: 'node1', type: 'n8n-nodes-base.httpRequest', name: 'HTTP', position: [0, 0], parameters: {}, typeVersion: 1 } + ], + connections: {}, + settings: {} + } + }); + repository.saveTemplate(template, detail); + } + }); + + describe('getNodeTemplatesCount', () => { + it('should return correct count for node type searches', () => { + const webhookCount = repository.getNodeTemplatesCount(['n8n-nodes-base.webhook']); + expect(webhookCount).toBe(15); + + const httpCount = repository.getNodeTemplatesCount(['n8n-nodes-base.httpRequest']); + expect(httpCount).toBe(10); + + const bothCount = repository.getNodeTemplatesCount([ + 'n8n-nodes-base.webhook', + 'n8n-nodes-base.httpRequest' + ]); + expect(bothCount).toBe(25); // OR query, so all templates + }); + + it('should return 0 for non-existent node types', () => { + const count = repository.getNodeTemplatesCount(['non-existent-node']); + expect(count).toBe(0); + }); + }); + + describe('getSearchCount', () => { + it('should return correct count for search queries', () => { + const webhookSearchCount = repository.getSearchCount('webhook'); + expect(webhookSearchCount).toBe(10); + + const processingSearchCount = repository.getSearchCount('processing'); + expect(processingSearchCount).toBe(15); + + const noResultsCount = repository.getSearchCount('nonexistent'); + expect(noResultsCount).toBe(0); + }); + }); + + describe('getTaskTemplatesCount', () => { + it('should return correct count for task-based searches', () => { + const webhookTaskCount = repository.getTaskTemplatesCount('webhook_processing'); + expect(webhookTaskCount).toBeGreaterThan(0); + + const unknownTaskCount = repository.getTaskTemplatesCount('unknown_task'); + expect(unknownTaskCount).toBe(0); + }); + }); + + describe('getTemplateCount', () => { + it('should return total template count', () => { + const totalCount = repository.getTemplateCount(); + expect(totalCount).toBe(25); + }); + + it('should return 0 for empty database', () => { + repository.clearTemplates(); + const count = repository.getTemplateCount(); + expect(count).toBe(0); + }); + }); + + describe('getTemplatesForTask with pagination', () => { + it('should support pagination for task-based searches', () => { + const page1 = repository.getTemplatesForTask('webhook_processing', 5, 0); + const page2 = repository.getTemplatesForTask('webhook_processing', 5, 5); + + expect(page1).toHaveLength(5); + expect(page2).toHaveLength(5); + + // Should be different results + const page1Ids = page1.map(t => t.id); + const page2Ids = page2.map(t => t.id); + expect(page1Ids.filter(id => page2Ids.includes(id))).toHaveLength(0); + }); + }); + }); }); // Helper functions diff --git a/tests/unit/database/template-repository-core.test.ts b/tests/unit/database/template-repository-core.test.ts index 99191b7..bb8d094 100644 --- a/tests/unit/database/template-repository-core.test.ts +++ b/tests/unit/database/template-repository-core.test.ts @@ -173,6 +173,7 @@ describe('TemplateRepository - Core Functionality', () => { 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 @@ -182,14 +183,7 @@ describe('TemplateRepository - Core Functionality', () => { 'johndoe', 1, // verified JSON.stringify(['n8n-nodes-base.httpRequest', 'n8n-nodes-base.slack']), - JSON.stringify({ - 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: {} - }), + expect.any(String), // compressed workflow JSON JSON.stringify(['automation', 'integration']), 1000, // views '2024-01-01T00:00:00Z', @@ -316,7 +310,7 @@ describe('TemplateRepository - Core Functionality', () => { const results = repository.getTemplatesByNodes(['n8n-nodes-base.httpRequest'], 5); - expect(stmt.all).toHaveBeenCalledWith('%"n8n-nodes-base.httpRequest"%', 5); + expect(stmt.all).toHaveBeenCalledWith('%"n8n-nodes-base.httpRequest"%', 5, 0); expect(results).toEqual(mockTemplates); }); }); @@ -391,6 +385,102 @@ describe('TemplateRepository - Core Functionality', () => { 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', () => { diff --git a/tests/unit/mcp/parameter-validation.test.ts b/tests/unit/mcp/parameter-validation.test.ts index a93847d..e80935a 100644 --- a/tests/unit/mcp/parameter-validation.test.ts +++ b/tests/unit/mcp/parameter-validation.test.ts @@ -371,7 +371,7 @@ describe('Parameter Validation', () => { templateId: 123 }); - expect(mockGetTemplate).toHaveBeenCalledWith(123); + expect(mockGetTemplate).toHaveBeenCalledWith(123, 'full'); }); it('should convert string templateId to number', async () => { @@ -381,7 +381,7 @@ describe('Parameter Validation', () => { templateId: '123' }); - expect(mockGetTemplate).toHaveBeenCalledWith(123); + expect(mockGetTemplate).toHaveBeenCalledWith(123, 'full'); }); }); }); diff --git a/tests/unit/mcp/template-handlers.test.ts b/tests/unit/mcp/template-handlers.test.ts new file mode 100644 index 0000000..2e19116 --- /dev/null +++ b/tests/unit/mcp/template-handlers.test.ts @@ -0,0 +1,481 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { N8NDocumentationMCPServer } from '../../../src/mcp/server'; +import { NodeRepository } from '../../../src/database/node-repository'; +import { TemplateService, PaginatedResponse, TemplateMinimal } from '../../../src/templates/template-service'; +import { DatabaseAdapter } from '../../../src/database/database-adapter'; + +// Mock dependencies +vi.mock('../../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + }, + Logger: vi.fn().mockImplementation(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + })) +})); + +vi.mock('../../../src/database/node-repository'); +vi.mock('../../../src/templates/template-service'); +vi.mock('../../../src/database/database-adapter'); + +// Create testable server class to access private methods +class TestableMCPServer extends N8NDocumentationMCPServer { + public async testListTemplates(limit?: number, offset?: number, sortBy?: 'views' | 'created_at' | 'name'): Promise { + return (this as any).listTemplates(limit, offset, sortBy); + } + + public async testGetTemplate(templateId: number, mode?: 'full' | 'nodes_only' | 'structure'): Promise { + return (this as any).getTemplate(templateId, mode); + } + + public async testGetDatabaseStatistics(): Promise { + return (this as any).getDatabaseStatistics(); + } + + public async testListNodeTemplates(nodeTypes: string[], limit?: number, offset?: number): Promise { + return (this as any).listNodeTemplates(nodeTypes, limit, offset); + } + + public async testSearchTemplates(query: string, limit?: number, offset?: number): Promise { + return (this as any).searchTemplates(query, limit, offset); + } + + public async testGetTemplatesForTask(task: string, limit?: number, offset?: number): Promise { + return (this as any).getTemplatesForTask(task, limit, offset); + } +} + +describe('MCP Template Handlers', () => { + let server: TestableMCPServer; + let mockDb: DatabaseAdapter; + let mockNodeRepository: NodeRepository; + let mockTemplateService: TemplateService; + + beforeEach(() => { + vi.clearAllMocks(); + + mockDb = { + prepare: vi.fn(), + exec: vi.fn(), + close: vi.fn(), + pragma: vi.fn(), + transaction: vi.fn(), + checkFTS5Support: vi.fn(() => true), + inTransaction: false + } as any; + + mockNodeRepository = { + getTotalNodes: vi.fn(() => 500), + getAIToolsCount: vi.fn(() => 263), + getTriggersCount: vi.fn(() => 104), + getDocsCount: vi.fn(() => 435) + } as any; + + mockTemplateService = { + listTemplates: vi.fn(), + getTemplate: vi.fn(), + listNodeTemplates: vi.fn(), + searchTemplates: vi.fn(), + getTemplatesForTask: vi.fn(), + getTemplateStats: vi.fn() + } as any; + + (NodeRepository as any).mockImplementation(() => mockNodeRepository); + (TemplateService as any).mockImplementation(() => mockTemplateService); + + // Set environment variable for in-memory database + process.env.NODE_DB_PATH = ':memory:'; + server = new TestableMCPServer(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('listTemplates', () => { + it('should return paginated template list with default parameters', async () => { + const mockResponse: PaginatedResponse = { + items: [ + { id: 1, name: 'Template A', views: 200, nodeCount: 3 }, + { id: 2, name: 'Template B', views: 150, nodeCount: 2 } + ], + total: 25, + limit: 10, + offset: 0, + hasMore: true + }; + + mockTemplateService.listTemplates = vi.fn().mockResolvedValue(mockResponse); + + const result = await server.testListTemplates(); + + expect(result).toEqual({ + templates: mockResponse.items, + pagination: { + total: 25, + limit: 10, + offset: 0, + hasMore: true + } + }); + + expect(mockTemplateService.listTemplates).toHaveBeenCalledWith(10, 0, 'views'); + }); + + it('should handle custom pagination parameters', async () => { + const mockResponse: PaginatedResponse = { + items: [ + { id: 3, name: 'Template C', views: 100, nodeCount: 1 } + ], + total: 25, + limit: 5, + offset: 20, + hasMore: false + }; + + mockTemplateService.listTemplates = vi.fn().mockResolvedValue(mockResponse); + + const result = await server.testListTemplates(5, 20, 'name'); + + expect(result).toEqual({ + templates: mockResponse.items, + pagination: { + total: 25, + limit: 5, + offset: 20, + hasMore: false + } + }); + + expect(mockTemplateService.listTemplates).toHaveBeenCalledWith(5, 20, 'name'); + }); + + it('should handle empty results', async () => { + const mockResponse: PaginatedResponse = { + items: [], + total: 0, + limit: 10, + offset: 0, + hasMore: false + }; + + mockTemplateService.listTemplates = vi.fn().mockResolvedValue(mockResponse); + + const result = await server.testListTemplates(); + + expect(result.templates).toHaveLength(0); + expect(result.pagination.total).toBe(0); + }); + }); + + describe('getTemplate with mode parameter', () => { + it('should return full template by default', async () => { + const mockTemplate = { + id: 1, + name: 'Test Template', + description: 'Test description', + author: { name: 'Test Author', username: 'test', verified: true }, + nodes: ['n8n-nodes-base.webhook'], + views: 100, + created: '2024-01-01T00:00:00Z', + url: 'https://n8n.io/workflows/1', + workflow: { + nodes: [{ id: 'node1', type: 'n8n-nodes-base.webhook' }], + connections: {}, + settings: {} + } + }; + + mockTemplateService.getTemplate = vi.fn().mockResolvedValue(mockTemplate); + + const result = await server.testGetTemplate(1); + + expect(result).toEqual(mockTemplate); + expect(mockTemplateService.getTemplate).toHaveBeenCalledWith(1, 'full'); + }); + + it('should return nodes_only mode correctly', async () => { + const mockTemplate = { + id: 1, + name: 'Test Template', + nodes: [ + { type: 'n8n-nodes-base.webhook', name: 'Webhook' }, + { type: 'n8n-nodes-base.slack', name: 'Slack' } + ] + }; + + mockTemplateService.getTemplate = vi.fn().mockResolvedValue(mockTemplate); + + const result = await server.testGetTemplate(1, 'nodes_only'); + + expect(result).toEqual(mockTemplate); + expect(mockTemplateService.getTemplate).toHaveBeenCalledWith(1, 'nodes_only'); + }); + + it('should return structure mode correctly', async () => { + const mockTemplate = { + id: 1, + name: 'Test Template', + nodes: [ + { id: 'node1', type: 'n8n-nodes-base.webhook', name: 'Webhook', position: [100, 100] } + ], + connections: { node1: { main: [[{ node: 'node2', type: 'main', index: 0 }]] } } + }; + + mockTemplateService.getTemplate = vi.fn().mockResolvedValue(mockTemplate); + + const result = await server.testGetTemplate(1, 'structure'); + + expect(result).toEqual(mockTemplate); + expect(mockTemplateService.getTemplate).toHaveBeenCalledWith(1, 'structure'); + }); + + it('should handle non-existent template', async () => { + mockTemplateService.getTemplate = vi.fn().mockResolvedValue(null); + + const result = await server.testGetTemplate(999); + + expect(result).toEqual({ + error: 'Template not found', + tip: "Use list_templates, list_node_templates or search_templates to find available templates" + }); + }); + }); + + describe('Enhanced template tools with pagination', () => { + describe('listNodeTemplates', () => { + it('should handle pagination correctly', async () => { + const mockResponse = { + items: [ + { id: 1, name: 'Webhook Template', nodes: ['n8n-nodes-base.webhook'], views: 200 } + ], + total: 15, + limit: 10, + offset: 5, + hasMore: true + }; + + mockTemplateService.listNodeTemplates = vi.fn().mockResolvedValue(mockResponse); + + const result = await server.testListNodeTemplates(['n8n-nodes-base.webhook'], 10, 5); + + expect(result).toEqual({ + templates: mockResponse.items, + pagination: { + total: 15, + limit: 10, + offset: 5, + hasMore: true + } + }); + + expect(mockTemplateService.listNodeTemplates).toHaveBeenCalledWith(['n8n-nodes-base.webhook'], 10, 5); + }); + }); + + describe('searchTemplates', () => { + it('should handle pagination correctly', async () => { + const mockResponse = { + items: [ + { id: 2, name: 'Search Result', description: 'Found template', views: 150 } + ], + total: 8, + limit: 20, + offset: 0, + hasMore: false + }; + + mockTemplateService.searchTemplates = vi.fn().mockResolvedValue(mockResponse); + + const result = await server.testSearchTemplates('webhook', 20, 0); + + expect(result).toEqual({ + templates: mockResponse.items, + pagination: { + total: 8, + limit: 20, + offset: 0, + hasMore: false + } + }); + + expect(mockTemplateService.searchTemplates).toHaveBeenCalledWith('webhook', 20, 0); + }); + }); + + describe('getTemplatesForTask', () => { + it('should handle pagination correctly', async () => { + const mockResponse = { + items: [ + { id: 3, name: 'AI Template', nodes: ['@n8n/n8n-nodes-langchain.openAi'], views: 300 } + ], + total: 12, + limit: 10, + offset: 10, + hasMore: true + }; + + mockTemplateService.getTemplatesForTask = vi.fn().mockResolvedValue(mockResponse); + + const result = await server.testGetTemplatesForTask('ai_automation', 10, 10); + + expect(result).toEqual({ + templates: mockResponse.items, + pagination: { + total: 12, + limit: 10, + offset: 10, + hasMore: true + } + }); + + expect(mockTemplateService.getTemplatesForTask).toHaveBeenCalledWith('ai_automation', 10, 10); + }); + }); + }); + + describe('getDatabaseStatistics with template metrics', () => { + it('should include template statistics', async () => { + const mockTemplateStats = { + totalTemplates: 100, + averageViews: 250, + minViews: 10, + maxViews: 1000, + topUsedNodes: [ + { node: 'n8n-nodes-base.webhook', count: 45 }, + { node: 'n8n-nodes-base.slack', count: 30 } + ] + }; + + mockTemplateService.getTemplateStats = vi.fn().mockResolvedValue(mockTemplateStats); + + const result = await server.testGetDatabaseStatistics(); + + expect(result).toEqual(expect.objectContaining({ + nodeStatistics: { + totalNodes: 500, + aiTools: 263, + triggers: 104, + docsAvailable: 435, + docsCoverage: '87%' + }, + templateStatistics: { + totalTemplates: 100, + averageViews: 250, + minViews: 10, + maxViews: 1000, + topUsedNodes: [ + { node: 'n8n-nodes-base.webhook', count: 45 }, + { node: 'n8n-nodes-base.slack', count: 30 } + ] + } + })); + + expect(mockTemplateService.getTemplateStats).toHaveBeenCalled(); + }); + + it('should handle template service errors gracefully', async () => { + mockTemplateService.getTemplateStats = vi.fn().mockRejectedValue(new Error('Template stats failed')); + + const result = await server.testGetDatabaseStatistics(); + + expect(result).toEqual(expect.objectContaining({ + nodeStatistics: expect.any(Object), + templateStatistics: { + error: 'Unable to fetch template statistics' + } + })); + }); + + it('should handle missing template service', async () => { + // Create server without template service + const serverWithoutTemplates = new TestableMCPServer(); + (serverWithoutTemplates as any).templateService = undefined; + + const result = await serverWithoutTemplates.testGetDatabaseStatistics(); + + expect(result).toEqual(expect.objectContaining({ + nodeStatistics: expect.any(Object), + templateStatistics: { + error: 'Template service not available' + } + })); + }); + }); + + describe('Error handling', () => { + it('should handle service errors in listTemplates', async () => { + mockTemplateService.listTemplates = vi.fn().mockRejectedValue(new Error('Database error')); + + await expect(server.testListTemplates()).rejects.toThrow('Database error'); + }); + + it('should handle service errors in getTemplate', async () => { + mockTemplateService.getTemplate = vi.fn().mockRejectedValue(new Error('Template fetch error')); + + await expect(server.testGetTemplate(1)).rejects.toThrow('Template fetch error'); + }); + + it('should validate template mode parameter', async () => { + const result = await server.testGetTemplate(1, 'invalid_mode' as any); + + expect(mockTemplateService.getTemplate).toHaveBeenCalledWith(1, 'invalid_mode'); + }); + + it('should handle invalid pagination parameters', async () => { + const mockResponse = { + items: [], + total: 0, + limit: 10, + offset: 0, + hasMore: false + }; + + mockTemplateService.listTemplates = vi.fn().mockResolvedValue(mockResponse); + + // Should handle negative values gracefully + const result = await server.testListTemplates(-5, -10, 'views'); + + expect(mockTemplateService.listTemplates).toHaveBeenCalledWith(-5, -10, 'views'); + expect(result.pagination.limit).toBe(10); + expect(result.pagination.offset).toBe(0); + }); + }); + + describe('Integration with existing functionality', () => { + it('should maintain backward compatibility', async () => { + // Existing getTemplate method should work without mode parameter + const mockTemplate = { id: 1, name: 'Test' }; + mockTemplateService.getTemplate = vi.fn().mockResolvedValue(mockTemplate); + + const result = await server.testGetTemplate(1); + + expect(mockTemplateService.getTemplate).toHaveBeenCalledWith(1, 'full'); + }); + + it('should work with existing template tools', async () => { + // Test that existing functionality isn't broken + const mockResponse = { + items: [{ id: 1, name: 'Template' }], + total: 1, + limit: 10, + offset: 0, + hasMore: false + }; + + mockTemplateService.listNodeTemplates = vi.fn().mockResolvedValue(mockResponse); + + const result = await server.testListNodeTemplates(['n8n-nodes-base.webhook']); + + expect(mockTemplateService.listNodeTemplates).toHaveBeenCalledWith(['n8n-nodes-base.webhook'], 10, 0); + expect(result.templates).toBeDefined(); + expect(result.pagination).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/mcp/tools.test.ts b/tests/unit/mcp/tools.test.ts index c755a11..7d8620f 100644 --- a/tests/unit/mcp/tools.test.ts +++ b/tests/unit/mcp/tools.test.ts @@ -254,7 +254,7 @@ describe('n8nDocumentationToolsFinal', () => { discovery: ['list_nodes', 'search_nodes', 'list_ai_tools'], configuration: ['get_node_info', 'get_node_essentials', 'get_node_documentation'], validation: ['validate_node_operation', 'validate_workflow', 'validate_node_minimal'], - templates: ['list_tasks', 'get_node_for_task', 'search_templates'], + templates: ['list_tasks', 'get_node_for_task', 'search_templates', 'list_templates', 'get_template', 'list_node_templates'], documentation: ['tools_documentation'] }; @@ -317,4 +317,83 @@ describe('n8nDocumentationToolsFinal', () => { }); }); }); + + describe('New Template Tools', () => { + describe('list_templates', () => { + const tool = n8nDocumentationToolsFinal.find(t => t.name === 'list_templates'); + + it('should exist and be properly defined', () => { + expect(tool).toBeDefined(); + expect(tool?.description).toContain('minimal data'); + }); + + it('should have correct parameters', () => { + expect(tool?.inputSchema.properties).toHaveProperty('limit'); + expect(tool?.inputSchema.properties).toHaveProperty('offset'); + expect(tool?.inputSchema.properties).toHaveProperty('sortBy'); + + const limitParam = tool?.inputSchema.properties.limit; + expect(limitParam.type).toBe('number'); + expect(limitParam.minimum).toBe(1); + expect(limitParam.maximum).toBe(100); + + const offsetParam = tool?.inputSchema.properties.offset; + expect(offsetParam.type).toBe('number'); + expect(offsetParam.minimum).toBe(0); + + const sortByParam = tool?.inputSchema.properties.sortBy; + expect(sortByParam.enum).toEqual(['views', 'created_at', 'name']); + }); + + it('should have no required parameters', () => { + expect(tool?.inputSchema.required).toBeUndefined(); + }); + }); + + describe('get_template (enhanced)', () => { + const tool = n8nDocumentationToolsFinal.find(t => t.name === 'get_template'); + + it('should exist and support mode parameter', () => { + expect(tool).toBeDefined(); + expect(tool?.description).toContain('mode'); + }); + + it('should have mode parameter with correct values', () => { + expect(tool?.inputSchema.properties).toHaveProperty('mode'); + + const modeParam = tool?.inputSchema.properties.mode; + expect(modeParam.enum).toEqual(['nodes_only', 'structure', 'full']); + expect(modeParam.default).toBe('full'); + }); + + it('should require templateId parameter', () => { + expect(tool?.inputSchema.required).toContain('templateId'); + }); + }); + + describe('Enhanced pagination support', () => { + const paginatedTools = ['list_node_templates', 'search_templates', 'get_templates_for_task']; + + paginatedTools.forEach(toolName => { + describe(toolName, () => { + const tool = n8nDocumentationToolsFinal.find(t => t.name === toolName); + + it('should support limit parameter', () => { + expect(tool?.inputSchema.properties).toHaveProperty('limit'); + const limitParam = tool?.inputSchema.properties.limit; + expect(limitParam.type).toBe('number'); + expect(limitParam.minimum).toBeGreaterThanOrEqual(1); + expect(limitParam.maximum).toBeGreaterThanOrEqual(50); + }); + + it('should support offset parameter', () => { + expect(tool?.inputSchema.properties).toHaveProperty('offset'); + const offsetParam = tool?.inputSchema.properties.offset; + expect(offsetParam.type).toBe('number'); + expect(offsetParam.minimum).toBe(0); + }); + }); + }); + }); + }); }); \ No newline at end of file diff --git a/tests/unit/services/template-service.test.ts b/tests/unit/services/template-service.test.ts new file mode 100644 index 0000000..fda771b --- /dev/null +++ b/tests/unit/services/template-service.test.ts @@ -0,0 +1,577 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { TemplateService, PaginatedResponse, TemplateInfo, TemplateMinimal } from '../../../src/templates/template-service'; +import { TemplateRepository, StoredTemplate } from '../../../src/templates/template-repository'; +import { DatabaseAdapter } from '../../../src/database/database-adapter'; + +// Mock the logger +vi.mock('../../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + } +})); + +// Mock the template repository +vi.mock('../../../src/templates/template-repository'); + +// Mock template fetcher - only imported when needed +vi.mock('../../../src/templates/template-fetcher', () => ({ + TemplateFetcher: vi.fn().mockImplementation(() => ({ + fetchTemplates: vi.fn(), + fetchAllTemplateDetails: vi.fn() + })) +})); + +describe('TemplateService', () => { + let service: TemplateService; + let mockDb: DatabaseAdapter; + let mockRepository: TemplateRepository; + + const createMockTemplate = (id: number, overrides: any = {}): StoredTemplate => ({ + id, + workflow_id: id, + name: overrides.name || `Template ${id}`, + description: overrides.description || `Description for template ${id}`, + author_name: overrides.author_name || 'Test Author', + author_username: overrides.author_username || 'testuser', + author_verified: overrides.author_verified !== undefined ? overrides.author_verified : 1, + nodes_used: JSON.stringify(overrides.nodes_used || ['n8n-nodes-base.webhook']), + workflow_json: JSON.stringify(overrides.workflow || { + nodes: [ + { + id: 'node1', + type: 'n8n-nodes-base.webhook', + name: 'Webhook', + position: [100, 100], + parameters: {} + } + ], + connections: {}, + settings: {} + }), + categories: JSON.stringify(overrides.categories || ['automation']), + views: overrides.views || 100, + created_at: overrides.created_at || '2024-01-01T00:00:00Z', + updated_at: overrides.updated_at || '2024-01-01T00:00:00Z', + url: overrides.url || `https://n8n.io/workflows/${id}`, + scraped_at: '2024-01-01T00:00:00Z' + }); + + beforeEach(() => { + vi.clearAllMocks(); + + mockDb = {} as DatabaseAdapter; + + // Create mock repository with all methods + mockRepository = { + getTemplatesByNodes: vi.fn(), + getNodeTemplatesCount: vi.fn(), + getTemplate: vi.fn(), + searchTemplates: vi.fn(), + getSearchCount: vi.fn(), + getTemplatesForTask: vi.fn(), + getTaskTemplatesCount: vi.fn(), + getAllTemplates: vi.fn(), + getTemplateCount: vi.fn(), + getTemplateStats: vi.fn(), + getExistingTemplateIds: vi.fn(), + clearTemplates: vi.fn(), + saveTemplate: vi.fn(), + rebuildTemplateFTS: vi.fn() + } as any; + + // Mock the constructor + (TemplateRepository as any).mockImplementation(() => mockRepository); + + service = new TemplateService(mockDb); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('listNodeTemplates', () => { + it('should return paginated node templates', async () => { + const mockTemplates = [ + createMockTemplate(1, { name: 'Webhook Template' }), + createMockTemplate(2, { name: 'HTTP Template' }) + ]; + + mockRepository.getTemplatesByNodes = vi.fn().mockReturnValue(mockTemplates); + mockRepository.getNodeTemplatesCount = vi.fn().mockReturnValue(10); + + const result = await service.listNodeTemplates(['n8n-nodes-base.webhook'], 5, 0); + + expect(result).toEqual({ + items: expect.arrayContaining([ + expect.objectContaining({ + id: 1, + name: 'Webhook Template', + author: expect.objectContaining({ + name: 'Test Author', + username: 'testuser', + verified: true + }), + nodes: ['n8n-nodes-base.webhook'], + views: 100 + }) + ]), + total: 10, + limit: 5, + offset: 0, + hasMore: true + }); + + expect(mockRepository.getTemplatesByNodes).toHaveBeenCalledWith(['n8n-nodes-base.webhook'], 5, 0); + expect(mockRepository.getNodeTemplatesCount).toHaveBeenCalledWith(['n8n-nodes-base.webhook']); + }); + + it('should handle pagination correctly', async () => { + mockRepository.getTemplatesByNodes = vi.fn().mockReturnValue([]); + mockRepository.getNodeTemplatesCount = vi.fn().mockReturnValue(25); + + const result = await service.listNodeTemplates(['n8n-nodes-base.webhook'], 10, 20); + + expect(result.hasMore).toBe(false); // 20 + 10 >= 25 + expect(result.offset).toBe(20); + expect(result.limit).toBe(10); + }); + + it('should use default pagination parameters', async () => { + mockRepository.getTemplatesByNodes = vi.fn().mockReturnValue([]); + mockRepository.getNodeTemplatesCount = vi.fn().mockReturnValue(0); + + await service.listNodeTemplates(['n8n-nodes-base.webhook']); + + expect(mockRepository.getTemplatesByNodes).toHaveBeenCalledWith(['n8n-nodes-base.webhook'], 10, 0); + }); + }); + + describe('getTemplate', () => { + const mockWorkflow = { + nodes: [ + { + id: 'node1', + type: 'n8n-nodes-base.webhook', + name: 'Webhook', + position: [100, 100], + parameters: { path: 'test' } + }, + { + id: 'node2', + type: 'n8n-nodes-base.slack', + name: 'Slack', + position: [300, 100], + parameters: { channel: '#general' } + } + ], + connections: { + 'node1': { + 'main': [ + [{ 'node': 'node2', 'type': 'main', 'index': 0 }] + ] + } + }, + settings: { timezone: 'UTC' } + }; + + it('should return template in nodes_only mode', async () => { + const mockTemplate = createMockTemplate(1, { workflow: mockWorkflow }); + mockRepository.getTemplate = vi.fn().mockReturnValue(mockTemplate); + + const result = await service.getTemplate(1, 'nodes_only'); + + expect(result).toEqual({ + id: 1, + name: 'Template 1', + nodes: [ + { type: 'n8n-nodes-base.webhook', name: 'Webhook' }, + { type: 'n8n-nodes-base.slack', name: 'Slack' } + ] + }); + }); + + it('should return template in structure mode', async () => { + const mockTemplate = createMockTemplate(1, { workflow: mockWorkflow }); + mockRepository.getTemplate = vi.fn().mockReturnValue(mockTemplate); + + const result = await service.getTemplate(1, 'structure'); + + expect(result).toEqual({ + id: 1, + name: 'Template 1', + nodes: [ + { + id: 'node1', + type: 'n8n-nodes-base.webhook', + name: 'Webhook', + position: [100, 100] + }, + { + id: 'node2', + type: 'n8n-nodes-base.slack', + name: 'Slack', + position: [300, 100] + } + ], + connections: mockWorkflow.connections + }); + }); + + it('should return full template in full mode', async () => { + const mockTemplate = createMockTemplate(1, { workflow: mockWorkflow }); + mockRepository.getTemplate = vi.fn().mockReturnValue(mockTemplate); + + const result = await service.getTemplate(1, 'full'); + + expect(result).toEqual(expect.objectContaining({ + id: 1, + name: 'Template 1', + description: 'Description for template 1', + author: { + name: 'Test Author', + username: 'testuser', + verified: true + }, + nodes: ['n8n-nodes-base.webhook'], + views: 100, + workflow: mockWorkflow + })); + }); + + it('should return null for non-existent template', async () => { + mockRepository.getTemplate = vi.fn().mockReturnValue(null); + + const result = await service.getTemplate(999); + + expect(result).toBeNull(); + }); + + it('should handle templates with no workflow nodes', async () => { + const mockTemplate = createMockTemplate(1, { workflow: { connections: {}, settings: {} } }); + mockRepository.getTemplate = vi.fn().mockReturnValue(mockTemplate); + + const result = await service.getTemplate(1, 'nodes_only'); + + expect(result.nodes).toEqual([]); + }); + }); + + describe('searchTemplates', () => { + it('should return paginated search results', async () => { + const mockTemplates = [ + createMockTemplate(1, { name: 'Webhook Automation' }), + createMockTemplate(2, { name: 'Webhook Processing' }) + ]; + + mockRepository.searchTemplates = vi.fn().mockReturnValue(mockTemplates); + mockRepository.getSearchCount = vi.fn().mockReturnValue(15); + + const result = await service.searchTemplates('webhook', 10, 5); + + expect(result).toEqual({ + items: expect.arrayContaining([ + expect.objectContaining({ id: 1, name: 'Webhook Automation' }), + expect.objectContaining({ id: 2, name: 'Webhook Processing' }) + ]), + total: 15, + limit: 10, + offset: 5, + hasMore: false // 5 + 10 >= 15 + }); + + expect(mockRepository.searchTemplates).toHaveBeenCalledWith('webhook', 10, 5); + expect(mockRepository.getSearchCount).toHaveBeenCalledWith('webhook'); + }); + + it('should use default parameters', async () => { + mockRepository.searchTemplates = vi.fn().mockReturnValue([]); + mockRepository.getSearchCount = vi.fn().mockReturnValue(0); + + await service.searchTemplates('test'); + + expect(mockRepository.searchTemplates).toHaveBeenCalledWith('test', 20, 0); + }); + }); + + describe('getTemplatesForTask', () => { + it('should return paginated task templates', async () => { + const mockTemplates = [ + createMockTemplate(1, { name: 'AI Workflow' }), + createMockTemplate(2, { name: 'ML Pipeline' }) + ]; + + mockRepository.getTemplatesForTask = vi.fn().mockReturnValue(mockTemplates); + mockRepository.getTaskTemplatesCount = vi.fn().mockReturnValue(8); + + const result = await service.getTemplatesForTask('ai_automation', 5, 3); + + expect(result).toEqual({ + items: expect.arrayContaining([ + expect.objectContaining({ id: 1, name: 'AI Workflow' }), + expect.objectContaining({ id: 2, name: 'ML Pipeline' }) + ]), + total: 8, + limit: 5, + offset: 3, + hasMore: false // 3 + 5 >= 8 + }); + + expect(mockRepository.getTemplatesForTask).toHaveBeenCalledWith('ai_automation', 5, 3); + expect(mockRepository.getTaskTemplatesCount).toHaveBeenCalledWith('ai_automation'); + }); + }); + + describe('listTemplates', () => { + it('should return paginated minimal template data', async () => { + const mockTemplates = [ + createMockTemplate(1, { + name: 'Template A', + nodes_used: ['n8n-nodes-base.webhook', 'n8n-nodes-base.slack'], + views: 200 + }), + createMockTemplate(2, { + name: 'Template B', + nodes_used: ['n8n-nodes-base.httpRequest'], + views: 150 + }) + ]; + + mockRepository.getAllTemplates = vi.fn().mockReturnValue(mockTemplates); + mockRepository.getTemplateCount = vi.fn().mockReturnValue(50); + + const result = await service.listTemplates(10, 20, 'views'); + + expect(result).toEqual({ + items: [ + { id: 1, name: 'Template A', views: 200, nodeCount: 2 }, + { id: 2, name: 'Template B', views: 150, nodeCount: 1 } + ], + total: 50, + limit: 10, + offset: 20, + hasMore: true // 20 + 10 < 50 + }); + + expect(mockRepository.getAllTemplates).toHaveBeenCalledWith(10, 20, 'views'); + expect(mockRepository.getTemplateCount).toHaveBeenCalled(); + }); + + it('should use default parameters', async () => { + mockRepository.getAllTemplates = vi.fn().mockReturnValue([]); + mockRepository.getTemplateCount = vi.fn().mockReturnValue(0); + + await service.listTemplates(); + + expect(mockRepository.getAllTemplates).toHaveBeenCalledWith(10, 0, 'views'); + }); + + it('should handle different sort orders', async () => { + mockRepository.getAllTemplates = vi.fn().mockReturnValue([]); + mockRepository.getTemplateCount = vi.fn().mockReturnValue(0); + + await service.listTemplates(5, 0, 'name'); + + expect(mockRepository.getAllTemplates).toHaveBeenCalledWith(5, 0, 'name'); + }); + }); + + describe('listAvailableTasks', () => { + it('should return list of available tasks', () => { + const tasks = service.listAvailableTasks(); + + expect(tasks).toEqual([ + 'ai_automation', + 'data_sync', + 'webhook_processing', + 'email_automation', + 'slack_integration', + 'data_transformation', + 'file_processing', + 'scheduling', + 'api_integration', + 'database_operations' + ]); + }); + }); + + describe('getTemplateStats', () => { + it('should return template statistics', async () => { + const mockStats = { + totalTemplates: 100, + averageViews: 250, + topUsedNodes: [ + { node: 'n8n-nodes-base.webhook', count: 45 }, + { node: 'n8n-nodes-base.slack', count: 30 } + ] + }; + + mockRepository.getTemplateStats = vi.fn().mockReturnValue(mockStats); + + const result = await service.getTemplateStats(); + + expect(result).toEqual(mockStats); + expect(mockRepository.getTemplateStats).toHaveBeenCalled(); + }); + }); + + describe('fetchAndUpdateTemplates', () => { + it('should handle rebuild mode', async () => { + const mockFetcher = { + fetchTemplates: vi.fn().mockResolvedValue([ + { id: 1, name: 'Template 1' }, + { id: 2, name: 'Template 2' } + ]), + fetchAllTemplateDetails: vi.fn().mockResolvedValue(new Map([ + [1, { id: 1, workflow: { nodes: [], connections: {}, settings: {} } }], + [2, { id: 2, workflow: { nodes: [], connections: {}, settings: {} } }] + ])) + }; + + // Mock dynamic import + vi.doMock('../../../src/templates/template-fetcher', () => ({ + TemplateFetcher: vi.fn(() => mockFetcher) + })); + + mockRepository.clearTemplates = vi.fn(); + mockRepository.saveTemplate = vi.fn(); + mockRepository.rebuildTemplateFTS = vi.fn(); + + const progressCallback = vi.fn(); + + await service.fetchAndUpdateTemplates(progressCallback, 'rebuild'); + + expect(mockRepository.clearTemplates).toHaveBeenCalled(); + expect(mockRepository.saveTemplate).toHaveBeenCalledTimes(2); + expect(mockRepository.rebuildTemplateFTS).toHaveBeenCalled(); + expect(progressCallback).toHaveBeenCalledWith('Complete', 2, 2); + }); + + it('should handle update mode with existing templates', async () => { + const mockFetcher = { + fetchTemplates: vi.fn().mockResolvedValue([ + { id: 1, name: 'Template 1' }, + { id: 2, name: 'Template 2' }, + { id: 3, name: 'Template 3' } + ]), + fetchAllTemplateDetails: vi.fn().mockResolvedValue(new Map([ + [3, { id: 3, workflow: { nodes: [], connections: {}, settings: {} } }] + ])) + }; + + // Mock dynamic import + vi.doMock('../../../src/templates/template-fetcher', () => ({ + TemplateFetcher: vi.fn(() => mockFetcher) + })); + + mockRepository.getExistingTemplateIds = vi.fn().mockReturnValue(new Set([1, 2])); + mockRepository.saveTemplate = vi.fn(); + mockRepository.rebuildTemplateFTS = vi.fn(); + + const progressCallback = vi.fn(); + + await service.fetchAndUpdateTemplates(progressCallback, 'update'); + + expect(mockRepository.getExistingTemplateIds).toHaveBeenCalled(); + expect(mockRepository.saveTemplate).toHaveBeenCalledTimes(1); // Only new template + expect(mockRepository.rebuildTemplateFTS).toHaveBeenCalled(); + }); + + it('should handle update mode with no new templates', async () => { + const mockFetcher = { + fetchTemplates: vi.fn().mockResolvedValue([ + { id: 1, name: 'Template 1' }, + { id: 2, name: 'Template 2' } + ]), + fetchAllTemplateDetails: vi.fn().mockResolvedValue(new Map()) + }; + + // Mock dynamic import + vi.doMock('../../../src/templates/template-fetcher', () => ({ + TemplateFetcher: vi.fn(() => mockFetcher) + })); + + mockRepository.getExistingTemplateIds = vi.fn().mockReturnValue(new Set([1, 2])); + mockRepository.saveTemplate = vi.fn(); + mockRepository.rebuildTemplateFTS = vi.fn(); + + const progressCallback = vi.fn(); + + await service.fetchAndUpdateTemplates(progressCallback, 'update'); + + expect(mockRepository.saveTemplate).not.toHaveBeenCalled(); + expect(mockRepository.rebuildTemplateFTS).not.toHaveBeenCalled(); + expect(progressCallback).toHaveBeenCalledWith('No new templates', 0, 0); + }); + + it('should handle errors during fetch', async () => { + // Mock the import to fail during constructor + const mockFetcher = function() { + throw new Error('Fetch failed'); + }; + + vi.doMock('../../../src/templates/template-fetcher', () => ({ + TemplateFetcher: mockFetcher + })); + + await expect(service.fetchAndUpdateTemplates()).rejects.toThrow('Fetch failed'); + }); + }); + + describe('formatTemplateInfo (private method behavior)', () => { + it('should format template data correctly through public methods', async () => { + const mockTemplate = createMockTemplate(1, { + name: 'Test Template', + description: 'Test Description', + author_name: 'John Doe', + author_username: 'johndoe', + author_verified: 1, + nodes_used: ['n8n-nodes-base.webhook', 'n8n-nodes-base.slack'], + views: 500, + created_at: '2024-01-15T10:30:00Z', + url: 'https://n8n.io/workflows/123' + }); + + mockRepository.searchTemplates = vi.fn().mockReturnValue([mockTemplate]); + mockRepository.getSearchCount = vi.fn().mockReturnValue(1); + + const result = await service.searchTemplates('test'); + + expect(result.items[0]).toEqual({ + id: 1, + name: 'Test Template', + description: 'Test Description', + author: { + name: 'John Doe', + username: 'johndoe', + verified: true + }, + nodes: ['n8n-nodes-base.webhook', 'n8n-nodes-base.slack'], + views: 500, + created: '2024-01-15T10:30:00Z', + url: 'https://n8n.io/workflows/123' + }); + }); + + it('should handle unverified authors', async () => { + const mockTemplate = createMockTemplate(1, { + author_verified: 0 // Explicitly set to 0 for unverified + }); + + // Override the helper to return exactly what we want + const unverifiedTemplate = { + ...mockTemplate, + author_verified: 0 + }; + + mockRepository.searchTemplates = vi.fn().mockReturnValue([unverifiedTemplate]); + mockRepository.getSearchCount = vi.fn().mockReturnValue(1); + + const result = await service.searchTemplates('test'); + + expect(result.items[0].author.verified).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/tests/utils/database-utils.ts b/tests/utils/database-utils.ts index 0659be7..92cf640 100644 --- a/tests/utils/database-utils.ts +++ b/tests/utils/database-utils.ts @@ -280,7 +280,7 @@ export function createTestTemplate(overrides: Partial = {}): T verified: false }, createdAt: overrides.createdAt || new Date().toISOString(), - totalViews: overrides.totalViews || 0, + totalViews: overrides.totalViews || 100, ...overrides }; }