Skip to main content
Glama
AIAnalysisService.test.ts25.3 kB
import { AIAnalysisService } from '../services/AIAnalysisService'; import { Logger } from 'winston'; import fs from 'fs/promises'; import path from 'path'; // Mock fs/promises jest.mock('fs/promises', () => ({ readFile: jest.fn() })); const mockedFs = jest.mocked(fs); /** * Test Data Factory - Centralized test data management * Provides consistent, well-documented test documents for various scenarios */ class TestDataFactory { // Test file path constants static readonly PATHS = { QUALITY_DOC: 'test-document-quality.md', DUPLICATE_DOC: 'test-document-duplicate.md', RELEVANCE_DOC: 'test-document-relevance.md', EMPTY_DOC: 'test-document-empty.md', NONEXISTENT_DOC: 'non-existent-document.md' } as const; // Expected metrics for quality document static readonly QUALITY_METRICS = { WORD_COUNT: 35, LINE_COUNT: 15, HEADER_COUNT: 1, HAS_CODE_BLOCKS: true, HAS_IMAGES: true, HAS_LINKS: true, HAS_TODOS: true } as const; // Expected metrics for relevance document static readonly RELEVANCE_METRICS = { WORD_COUNT: 14, MATCHED_KEYWORDS: ['user', 'authentication', 'session', 'management', 'security'], EXPECTED_MATCHES: 5 } as const; /** * Quality test document with comprehensive content features * Contains: headers, code blocks, images, links, TODOs */ static getQualityDocument(): string { return [ '# Test Document', '', 'This is a test document for quality analysis.', 'It has some content and a [link](http://example.com).', '![Alt text](image.jpg)', '', '```javascript', 'console.log("hello");', '```', '', 'TODO: Add more content.', 'Line 7', 'Line 8', 'Line 9', 'Line 10' ].join('\n'); } /** * Document with duplicate content for testing duplicate detection */ static getDuplicateDocument(): string { return [ 'Line 1', 'Line 2', 'Line 1', // Duplicate 'Line 3' ].join('\n'); } /** * Document with content relevant to authentication/security context */ static getRelevanceDocument(): string { return 'This document is about user authentication and session management. It also covers security best practices.'; } /** * Empty document for edge case testing */ static getEmptyDocument(): string { return ''; } } /** * Mock Factory - Centralized mock creation and management */ class MockFactory { /** * Creates a winston logger mock with all required methods */ static createLoggerMock(): Logger { return { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), } as any; } /** * Creates an OpenAI client mock with proper structure */ static createOpenAIMock(response?: any): any { const defaultResponse = { choices: [{ message: { content: JSON.stringify({ score: 0.9, insights: ['AI-generated insight'], suggestions: ['AI-generated suggestion'], confidence: 0.95 }) } }] }; return { chat: { completions: { create: jest.fn().mockResolvedValue(response || defaultResponse) } } }; } /** * Creates OpenAI constructor mock */ static createOpenAIConstructorMock(instance: any): jest.Mock { return jest.fn(() => instance); } /** * Creates fs.readFile mock with predefined document responses */ static createFileSystemMock(): jest.Mock { return jest.fn((filePath: string) => { const filename = path.basename(filePath); switch (filename) { case TestDataFactory.PATHS.QUALITY_DOC: return Promise.resolve(TestDataFactory.getQualityDocument()); case TestDataFactory.PATHS.DUPLICATE_DOC: return Promise.resolve(TestDataFactory.getDuplicateDocument()); case TestDataFactory.PATHS.RELEVANCE_DOC: return Promise.resolve(TestDataFactory.getRelevanceDocument()); case TestDataFactory.PATHS.EMPTY_DOC: return Promise.resolve(TestDataFactory.getEmptyDocument()); default: return Promise.reject(new Error('File not found')); } }); } } /** * AIAnalysisService Test Suite * * Tests the AI analysis service with both local pattern-based analysis * and OpenAI API integration. Covers quality analysis, duplicate detection, * and relevance scoring with comprehensive error handling. */ describe('AIAnalysisService', () => { let service: AIAnalysisService; let loggerMock: Logger; beforeEach(() => { jest.clearAllMocks(); loggerMock = MockFactory.createLoggerMock(); // Set up fs.readFile mock mockedFs.readFile.mockImplementation((filePath: string | Buffer | URL, encoding?: any) => { const filename = path.basename(filePath.toString()); // Test file content mapping const testFiles: Record<string, string> = { 'test-document-quality.md': TestDataFactory.getQualityDocument(), 'test-document-duplicate.md': TestDataFactory.getDuplicateDocument(), 'test-document-relevance.md': TestDataFactory.getRelevanceDocument(), 'test-document-empty.md': TestDataFactory.getEmptyDocument() }; if (testFiles.hasOwnProperty(filename)) { return Promise.resolve(testFiles[filename]); } else { return Promise.reject(new Error('File not found')); } }); }); describe('Constructor and Initialization', () => { beforeEach(() => { delete process.env.OPENAI_API_KEY; delete process.env.ANTHROPIC_API_KEY; }); it('should be defined', () => { service = new AIAnalysisService('local', loggerMock); expect(service).toBeDefined(); }); it('should log info for local AI provider', () => { service = new AIAnalysisService('local', loggerMock); expect(loggerMock.info).toHaveBeenCalledWith('Using local AI analysis (pattern-based)'); }); it('should log info for anthropic AI provider (not implemented)', () => { process.env.ANTHROPIC_API_KEY = 'test-anthropic-key'; service = new AIAnalysisService('anthropic', loggerMock); expect(loggerMock.info).toHaveBeenCalledWith('Anthropic client initialization not implemented'); delete process.env.ANTHROPIC_API_KEY; }); it('should log warning for unknown AI provider', () => { service = new AIAnalysisService('unknown' as any, loggerMock); expect(loggerMock.warn).toHaveBeenCalledWith('Unknown AI provider: unknown'); }); it('should initialize OpenAI client when API key is present', () => { process.env.OPENAI_API_KEY = 'test-key'; const mockOpenAIInstance = MockFactory.createOpenAIMock(); const MockOpenAIClass = MockFactory.createOpenAIConstructorMock(mockOpenAIInstance); service = new AIAnalysisService('openai', loggerMock, undefined, MockOpenAIClass); expect(MockOpenAIClass).toHaveBeenCalledWith({ apiKey: 'test-key' }); delete process.env.OPENAI_API_KEY; }); it('should log error if AI client initialization fails', () => { process.env.OPENAI_API_KEY = 'test-key'; const MockOpenAIClass = jest.fn(() => { throw new Error('Failed to load OpenAI'); }); service = new AIAnalysisService('openai', loggerMock, undefined, MockOpenAIClass); expect(loggerMock.error).toHaveBeenCalledWith('Failed to initialize AI client:', expect.any(Error)); delete process.env.OPENAI_API_KEY; }); }); describe('Local Analysis Mode', () => { beforeEach(() => { delete process.env.OPENAI_API_KEY; service = new AIAnalysisService('local', loggerMock); }); describe('analyzeQuality', () => { it('should analyze document quality locally', async () => { const documentPath = `path/to/${TestDataFactory.PATHS.QUALITY_DOC}`; const analysis = await service.analyzeQuality(documentPath); expect(fs.readFile).toHaveBeenCalledWith(documentPath, 'utf-8'); expect(analysis).toBeDefined(); expect(analysis.analysisType).toBe('quality'); expect(analysis.score).toBeGreaterThan(0); expect(analysis.insights).toContain(`Document length: ${TestDataFactory.QUALITY_METRICS.WORD_COUNT} words`); expect(analysis.suggestions).toContain('Complete pending TODO items'); expect(analysis.confidence).toBe(0.6); expect(analysis.documentId).toBeDefined(); expect(analysis.analyzedAt).toBeDefined(); }); it('should return fallback analysis on read error', async () => { const documentPath = `path/to/${TestDataFactory.PATHS.NONEXISTENT_DOC}`; const analysis = await service.analyzeQuality(documentPath); expect(fs.readFile).toHaveBeenCalledWith(documentPath, 'utf-8'); expect(loggerMock.error).toHaveBeenCalledWith('Failed to analyze document quality:', expect.any(Error)); expect(analysis.analysisType).toBe('quality'); expect(analysis.score).toBe(0.5); expect(analysis.insights).toContain('Analysis failed: Cannot read document: File not found'); expect(analysis.confidence).toBe(0.1); }); it('should handle empty document gracefully', async () => { const documentPath = `path/to/${TestDataFactory.PATHS.EMPTY_DOC}`; const analysis = await service.analyzeQuality(documentPath); expect(analysis.analysisType).toBe('quality'); expect(analysis.score).toBeGreaterThanOrEqual(0.5); // Base score // Accept either 0 or 1 words (empty string may have whitespace) expect(analysis.insights.some(insight => insight.includes('Document length: 0 words') || insight.includes('Document length: 1 words') )).toBe(true); expect(analysis.suggestions).toContain('Consider adding more detailed content'); }); }); describe('detectDuplicates', () => { it('should detect duplicates locally', async () => { const documentPath = `path/to/${TestDataFactory.PATHS.DUPLICATE_DOC}`; const analysis = await service.detectDuplicates(documentPath); expect(fs.readFile).toHaveBeenCalledWith(documentPath, 'utf-8'); expect(analysis).toBeDefined(); expect(analysis.analysisType).toBe('duplicate'); expect(analysis.score).toBeGreaterThan(0); expect(analysis.insights).toContain('Document contains significant duplicate content'); expect(analysis.suggestions).toContain('Remove or consolidate duplicate sections'); expect(analysis.confidence).toBe(0.75); }); it('should handle documents with no duplicates', async () => { const documentPath = `path/to/${TestDataFactory.PATHS.QUALITY_DOC}`; const analysis = await service.detectDuplicates(documentPath); expect(analysis.analysisType).toBe('duplicate'); expect(analysis.score).toBeGreaterThanOrEqual(0); expect(analysis.confidence).toBe(0.75); }); }); describe('calculateRelevance', () => { it('should calculate relevance locally', async () => { const documentPath = `path/to/${TestDataFactory.PATHS.RELEVANCE_DOC}`; const workContext = 'user authentication and session management security'; const analysis = await service.calculateRelevance(documentPath, workContext); const workKeywords = workContext.toLowerCase().split(/\s+/); const expectedMatches = TestDataFactory.RELEVANCE_METRICS.EXPECTED_MATCHES; const expectedKeywords = TestDataFactory.RELEVANCE_METRICS.MATCHED_KEYWORDS; expect(fs.readFile).toHaveBeenCalledWith(documentPath, 'utf-8'); expect(analysis).toBeDefined(); expect(analysis.analysisType).toBe('relevance'); expect(analysis.score).toBeGreaterThan(0); expect(analysis.insights).toContain(`Found ${expectedMatches} relevant keywords out of ${workKeywords.length}`); expect(analysis.insights).toContain(`Relevant terms: ${expectedKeywords.join(', ')}`); expect(analysis.confidence).toBe(0.6); }); it('should handle irrelevant content', async () => { const documentPath = `path/to/${TestDataFactory.PATHS.QUALITY_DOC}`; const workContext = 'completely unrelated topic xyz'; const analysis = await service.calculateRelevance(documentPath, workContext); expect(analysis.analysisType).toBe('relevance'); expect(analysis.score).toBeGreaterThanOrEqual(0); expect(analysis.suggestions).toContain('Consider adding more context related to the work being done'); }); }); describe('generateInsights', () => { it('should generate insights for a document', async () => { const documentPath = `path/to/${TestDataFactory.PATHS.QUALITY_DOC}`; const insights = await service.generateInsights(documentPath); const metrics = TestDataFactory.QUALITY_METRICS; expect(fs.readFile).toHaveBeenCalledWith(documentPath, 'utf-8'); expect(insights).toBeDefined(); expect(insights).toContain(`Document contains ${metrics.WORD_COUNT} words and ${metrics.LINE_COUNT} lines`); expect(insights).toContain('Contains code examples or technical snippets'); expect(insights).toContain('Includes visual elements (images/diagrams)'); expect(insights).toContain('Contains references to external resources'); expect(insights).toContain(`Well-structured with ${metrics.HEADER_COUNT} headers`); expect(insights).toContain('Contains pending work items (TODO/FIXME)'); }); it('should handle insights generation errors', async () => { const documentPath = `path/to/${TestDataFactory.PATHS.NONEXISTENT_DOC}`; const insights = await service.generateInsights(documentPath); expect(insights).toBeDefined(); expect(insights[0]).toContain('Error generating insights'); expect(loggerMock.error).toHaveBeenCalled(); }); }); }); describe('OpenAI Integration Mode', () => { let mockOpenAIInstance: any; let mockOpenAIConstructor: jest.Mock; beforeEach(() => { process.env.OPENAI_API_KEY = 'test-api-key'; mockOpenAIInstance = MockFactory.createOpenAIMock(); mockOpenAIConstructor = MockFactory.createOpenAIConstructorMock(mockOpenAIInstance); service = new AIAnalysisService('openai', loggerMock, mockOpenAIInstance); }); // Additional branch coverage tests describe('detectDuplicates with OpenAI', () => { it('should detect duplicates with OpenAI successfully', async () => { const duplicateResponse = { choices: [{ message: { content: JSON.stringify({ score: 0.8, insights: ['High duplicate content detected'], suggestions: ['Remove duplicate sections'], confidence: 0.9 }) } }] }; mockOpenAIInstance.chat.completions.create.mockResolvedValueOnce(duplicateResponse); const documentPath = `path/to/${TestDataFactory.PATHS.DUPLICATE_DOC}`; const analysis = await service.detectDuplicates(documentPath); expect(analysis.analysisType).toBe('duplicate'); expect(analysis.score).toBe(0.8); expect(analysis.insights).toContain('High duplicate content detected'); expect(analysis.confidence).toBe(0.9); }); it('should fallback to local analysis when OpenAI fails for duplicates', async () => { mockOpenAIInstance.chat.completions.create.mockRejectedValueOnce(new Error('API Error')); const documentPath = `path/to/${TestDataFactory.PATHS.DUPLICATE_DOC}`; const analysis = await service.detectDuplicates(documentPath); expect(loggerMock.error).toHaveBeenCalledWith('OpenAI duplicate analysis failed:', expect.any(Error)); expect(analysis.analysisType).toBe('duplicate'); expect(analysis.confidence).toBe(0.75); // Local fallback confidence }); }); afterEach(() => { delete process.env.OPENAI_API_KEY; }); it('should initialize OpenAI client if API key is present', () => { expect(service['aiClient']).toBe(mockOpenAIInstance); }); describe('analyzeQuality', () => { it('should analyze document quality with OpenAI', async () => { const documentPath = `path/to/${TestDataFactory.PATHS.QUALITY_DOC}`; const analysis = await service.analyzeQuality(documentPath); expect(fs.readFile).toHaveBeenCalledWith(documentPath, 'utf-8'); expect(mockOpenAIInstance.chat.completions.create).toHaveBeenCalledTimes(1); expect(analysis.analysisType).toBe('quality'); expect(analysis.score).toBe(0.9); expect(analysis.insights).toContain('AI-generated insight'); expect(analysis.confidence).toBe(0.95); expect(analysis.documentId).toBeDefined(); expect(analysis.analyzedAt).toBeDefined(); }); it('should fallback to local analysis if OpenAI call fails', async () => { mockOpenAIInstance.chat.completions.create.mockRejectedValueOnce(new Error('OpenAI API error')); const documentPath = `path/to/${TestDataFactory.PATHS.QUALITY_DOC}`; const analysis = await service.analyzeQuality(documentPath); expect(fs.readFile).toHaveBeenCalledWith(documentPath, 'utf-8'); expect(mockOpenAIInstance.chat.completions.create).toHaveBeenCalledTimes(1); expect(loggerMock.error).toHaveBeenCalledWith('OpenAI analysis failed:', expect.any(Error)); expect(analysis.analysisType).toBe('quality'); expect(analysis.score).toBeGreaterThan(0); expect(analysis.confidence).toBe(0.6); }); it('should handle malformed JSON response from OpenAI', async () => { mockOpenAIInstance.chat.completions.create.mockResolvedValueOnce({ choices: [{ message: { content: 'invalid json {' } }] }); const documentPath = `path/to/${TestDataFactory.PATHS.QUALITY_DOC}`; const analysis = await service.analyzeQuality(documentPath); expect(loggerMock.error).toHaveBeenCalledWith('OpenAI analysis failed:', expect.any(Error)); expect(analysis.analysisType).toBe('quality'); expect(analysis.confidence).toBe(0.6); // Fallback analysis }); it('should handle empty response from OpenAI', async () => { mockOpenAIInstance.chat.completions.create.mockResolvedValueOnce({ choices: [] }); const documentPath = `path/to/${TestDataFactory.PATHS.QUALITY_DOC}`; const analysis = await service.analyzeQuality(documentPath); expect(loggerMock.error).toHaveBeenCalledWith('OpenAI analysis failed:', expect.any(Error)); expect(analysis.confidence).toBe(0.6); // Fallback analysis }); }); describe('calculateRelevance', () => { it('should calculate relevance with OpenAI', async () => { const documentPath = `path/to/${TestDataFactory.PATHS.RELEVANCE_DOC}`; const workContext = 'user authentication and session management security'; const analysis = await service.calculateRelevance(documentPath, workContext); expect(fs.readFile).toHaveBeenCalledWith(documentPath, 'utf-8'); expect(mockOpenAIInstance.chat.completions.create).toHaveBeenCalledTimes(1); expect(analysis.analysisType).toBe('relevance'); expect(analysis.score).toBe(0.9); expect(analysis.insights).toContain('AI-generated insight'); expect(analysis.confidence).toBe(0.95); }); it('should fallback to local analysis if OpenAI relevance call fails', async () => { mockOpenAIInstance.chat.completions.create.mockRejectedValueOnce(new Error('OpenAI API error')); const documentPath = `path/to/${TestDataFactory.PATHS.RELEVANCE_DOC}`; const workContext = 'user authentication and session management security'; const analysis = await service.calculateRelevance(documentPath, workContext); expect(fs.readFile).toHaveBeenCalledWith(documentPath, 'utf-8'); expect(mockOpenAIInstance.chat.completions.create).toHaveBeenCalledTimes(1); expect(loggerMock.error).toHaveBeenCalledWith('OpenAI relevance analysis failed:', expect.any(Error)); expect(analysis.analysisType).toBe('relevance'); expect(analysis.score).toBeGreaterThan(0); expect(analysis.confidence).toBe(0.6); }); it('should validate OpenAI API call parameters', async () => { const documentPath = `path/to/${TestDataFactory.PATHS.RELEVANCE_DOC}`; const workContext = 'test context'; await service.calculateRelevance(documentPath, workContext); const call = mockOpenAIInstance.chat.completions.create.mock.calls[0][0]; expect(call.model).toBe('gpt-3.5-turbo'); expect(call.max_tokens).toBe(800); expect(call.temperature).toBe(0.3); expect(call.messages[0].content).toContain('test context'); }); }); describe('Edge Cases and Error Handling', () => { it('should handle network timeout errors', async () => { mockOpenAIInstance.chat.completions.create.mockRejectedValueOnce( new Error('Request timeout') ); const documentPath = `path/to/${TestDataFactory.PATHS.QUALITY_DOC}`; const analysis = await service.analyzeQuality(documentPath); expect(analysis.confidence).toBe(0.6); // Should fallback to local expect(loggerMock.error).toHaveBeenCalledWith('OpenAI analysis failed:', expect.any(Error)); }); it('should handle rate limit errors gracefully', async () => { const rateLimitError = new Error('Rate limit exceeded'); mockOpenAIInstance.chat.completions.create.mockRejectedValueOnce(rateLimitError); const documentPath = `path/to/${TestDataFactory.PATHS.QUALITY_DOC}`; const analysis = await service.analyzeQuality(documentPath); expect(analysis).toBeDefined(); expect(analysis.confidence).toBe(0.6); // Fallback analysis }); it('should handle partial API responses', async () => { mockOpenAIInstance.chat.completions.create.mockResolvedValueOnce({ choices: [{ message: { content: JSON.stringify({ score: 0.8, insights: ['Partial insight'] // Missing suggestions and confidence }) } }] }); const documentPath = `path/to/${TestDataFactory.PATHS.QUALITY_DOC}`; const analysis = await service.analyzeQuality(documentPath); expect(analysis.score).toBe(0.8); expect(analysis.insights).toContain('Partial insight'); expect(analysis.suggestions).toEqual(['No suggestions provided']); expect(analysis.confidence).toBe(0.7); // Default fallback }); }); describe('generateInsights with OpenAI', () => { it('should generate AI insights successfully', async () => { const insightsResponse = { choices: [{ message: { content: JSON.stringify({ insights: ['AI-generated structural insight', 'AI-detected content pattern'], suggestions: ['AI-suggested improvement'], confidence: 0.92 }) } }] }; mockOpenAIInstance.chat.completions.create.mockResolvedValueOnce(insightsResponse); const documentPath = `path/to/${TestDataFactory.PATHS.QUALITY_DOC}`; const insights = await service.generateInsights(documentPath); expect(insights).toContain('AI-generated structural insight'); expect(insights).toContain('AI-detected content pattern'); }); it('should fallback to local insights when OpenAI fails', async () => { mockOpenAIInstance.chat.completions.create.mockRejectedValueOnce(new Error('API Error')); const documentPath = `path/to/${TestDataFactory.PATHS.QUALITY_DOC}`; const insights = await service.generateInsights(documentPath); expect(loggerMock.error).toHaveBeenCalledWith('OpenAI insights failed:', expect.any(Error)); expect(insights).toBeDefined(); expect(Array.isArray(insights)).toBe(true); }); }); describe('Edge cases for API provider selection', () => { it('should handle missing API key gracefully', () => { delete process.env.OPENAI_API_KEY; const serviceWithoutKey = new AIAnalysisService('openai', loggerMock); expect(loggerMock.warn).toHaveBeenCalledWith('OpenAI API key not found, using local analysis'); }); it('should handle anthropic provider (not implemented)', () => { process.env.ANTHROPIC_API_KEY = 'test-key'; const anthropicService = new AIAnalysisService('anthropic', loggerMock); expect(loggerMock.info).toHaveBeenCalledWith('Anthropic client initialization not implemented'); delete process.env.ANTHROPIC_API_KEY; }); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Ghostseller/CastPlan_mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server