/** * Unit tests for utility functions, particularly validation functions */ import { LogLevel, Logger } from '../src/utils'; import { validateOllamaUrl, validateModelName, validatePluginSettings, safeParseJson, } from '../src/utils'; describe('Validation Functions', () => { describe('validateOllamaUrl', () => { it('should accept valid HTTP URLs', () => { expect(validateOllamaUrl('http://localhost:11434').valid).toBe(true); expect(validateOllamaUrl('https://ollama.example.com').valid).toBe(true); expect(validateOllamaUrl('http://192.168.1.100:8080').valid).toBe(true); }); it('should reject invalid URLs', () => { const invalidUrl = validateOllamaUrl('not-a-url'); expect(invalidUrl.valid).toBe(false); expect(invalidUrl.error).toContain('valid HTTP or HTTPS URL'); }); it('should reject URLs ending with slash', () => { const urlWithSlash = validateOllamaUrl('http://localhost:11434/'); expect(urlWithSlash.valid).toBe(false); expect(urlWithSlash.error).toContain('should not end with a slash'); }); it('should reject empty URLs', () => { const emptyUrl = validateOllamaUrl(''); expect(emptyUrl.valid).toBe(false); expect(emptyUrl.error).toContain('cannot be empty'); }); it('should reject non-string inputs', () => { const nonString = validateOllamaUrl(123 as any); expect(nonString.valid).toBe(false); }); it('should trim whitespace from URLs', () => { expect(validateOllamaUrl(' http://localhost:11434 ').valid).toBe(true); }); }); describe('validateModelName', () => { it('should accept valid model names', () => { expect(validateModelName('llama3').valid).toBe(true); expect(validateModelName('llama-2').valid).toBe(true); expect(validateModelName('llama.2').valid).toBe(true); expect(validateModelName('llama_2').valid).toBe(true); expect(validateModelName('llama-2-7b-instruct').valid).toBe(true); }); it('should reject invalid model names', () => { const invalidModel = validateModelName('llama@2'); expect(invalidModel.valid).toBe(false); expect(invalidModel.error).toContain( 'only contain letters, numbers, dots, dashes, underscores, and colons' ); }); it('should reject empty model names', () => { const emptyModel = validateModelName(''); expect(emptyModel.valid).toBe(false); expect(emptyModel.error).toContain('cannot be empty'); }); it('should reject model names that are too short', () => { const shortModel = validateModelName('a'); expect(shortModel.valid).toBe(false); expect(shortModel.error).toContain('at least 2 characters long'); }); it('should reject model names that are too long', () => { const longModel = validateModelName('a'.repeat(101)); expect(longModel.valid).toBe(false); expect(longModel.error).toContain('less than 100 characters long'); }); it('should reject non-string inputs', () => { const nonString = validateModelName(123 as any); expect(nonString.valid).toBe(false); }); it('should trim whitespace from model names', () => { expect(validateModelName(' llama3 ').valid).toBe(true); }); }); describe('validatePluginSettings', () => { it('should return empty array for valid settings', () => { const errors = validatePluginSettings({ ollamaUrl: 'http://localhost:11434', model: 'llama3', }); expect(errors).toEqual([]); }); it('should report URL validation errors', () => { const errors = validatePluginSettings({ ollamaUrl: 'invalid-url', model: 'llama3', }); expect(errors.length).toBe(1); expect(errors[0]).toContain('Ollama URL'); }); it('should report model validation errors', () => { const errors = validatePluginSettings({ ollamaUrl: 'http://localhost:11434', model: 'invalid@model', }); expect(errors.length).toBe(1); expect(errors[0]).toContain('Model'); }); it('should report multiple validation errors', () => { const errors = validatePluginSettings({ ollamaUrl: 'invalid-url', model: 'invalid@model', }); expect(errors.length).toBe(2); expect(errors[0]).toContain('Ollama URL'); expect(errors[1]).toContain('Model'); }); }); describe('safeParseJson', () => { it('should parse valid JSON', () => { const result = safeParseJson('{"key": "value"}'); expect(result).toEqual({ key: 'value' }); }); it('should throw on invalid JSON', () => { expect(() => safeParseJson('{invalid json}')).toThrow('Invalid JSON'); }); it('should throw on dangerous object keys', () => { expect(() => safeParseJson('{"constructor": "function(){}"}')).toThrow('dangerous'); expect(() => safeParseJson('{"prototype": "something"}')).toThrow('dangerous'); expect(() => safeParseJson('{"__proto__": "something"}')).toThrow('dangerous'); }); it('should allow legitimate content containing function-related words', () => { // Should not throw - these are legitimate values, not dangerous keys expect(() => safeParseJson('{"message": "The function constructor is used to create objects"}') ).not.toThrow(); expect(() => safeParseJson('{"content": "JavaScript prototype inheritance is powerful"}') ).not.toThrow(); expect(() => safeParseJson('{"code": "function example() { return true; }"}')).not.toThrow(); expect(() => safeParseJson('{"function": "alert"}')).not.toThrow(); // function as key is now allowed }); it('should throw on deeply nested JSON', () => { const deeplyNested = JSON.stringify({ value: 1 }); let current = deeplyNested; for (let i = 0; i < 25; i++) { current = `{ "nested": ${current} }`; } expect(() => safeParseJson(current)).toThrow('nesting too deep'); }); it('should throw on very large JSON', () => { const largeJson = '"' + 'x'.repeat(1000001) + '"'; // 1,000,001 characters expect(() => safeParseJson(largeJson)).toThrow('input too large'); }); it('should handle string vs object arguments correctly', () => { const stringArgs = '{"path": "test.md", "content": "test"}'; const objectArgs = { path: 'test.md', content: 'test' }; const stringResult = safeParseJson(stringArgs); const objectResult = safeParseJson(JSON.stringify(objectArgs)); expect(stringResult).toEqual(objectResult); }); it('should throw on non-string input', () => { expect(() => safeParseJson(123 as any)).toThrow('Input must be a string'); }); }); }); describe('Logger', () => { beforeEach(() => { // Reset logger level before each test Logger.setLevel(LogLevel.DEBUG); }); describe('setLevel', () => { it('should set level using string values', () => { Logger.setLevel('debug'); expect((Logger as any).minLevel).toBe(LogLevel.DEBUG); Logger.setLevel('info'); expect((Logger as any).minLevel).toBe(LogLevel.INFO); Logger.setLevel('warn'); expect((Logger as any).minLevel).toBe(LogLevel.WARN); Logger.setLevel('error'); expect((Logger as any).minLevel).toBe(LogLevel.ERROR); }); it('should set level using numeric values', () => { Logger.setLevel(LogLevel.DEBUG); expect((Logger as any).minLevel).toBe(LogLevel.DEBUG); Logger.setLevel(LogLevel.INFO); expect((Logger as any).minLevel).toBe(LogLevel.INFO); Logger.setLevel(LogLevel.WARN); expect((Logger as any).minLevel).toBe(LogLevel.WARN); Logger.setLevel(LogLevel.ERROR); expect((Logger as any).minLevel).toBe(LogLevel.ERROR); }); it('should default to DEBUG for unknown string levels', () => { Logger.setLevel('unknown-level'); expect((Logger as any).minLevel).toBe(LogLevel.DEBUG); }); it('should be case insensitive for string levels', () => { Logger.setLevel('DEBUG'); expect((Logger as any).minLevel).toBe(LogLevel.DEBUG); Logger.setLevel('Info'); expect((Logger as any).minLevel).toBe(LogLevel.INFO); }); }); describe('logging methods with different levels', () => { it('should log debug messages when level is DEBUG', () => { const consoleSpy = jest.spyOn(console, 'debug').mockImplementation(); Logger.setLevel('debug'); Logger.debug('test message', 'test-category'); Logger.info('test message', 'test-category'); Logger.warn('test message', 'test-category'); Logger.error('test message', 'test-category'); expect(consoleSpy).toHaveBeenCalledWith('[test-category] DEBUG: test message'); consoleSpy.mockRestore(); }); it('should not log debug messages when level is INFO', () => { const consoleSpy = jest.spyOn(console, 'debug').mockImplementation(); Logger.setLevel('info'); Logger.debug('test message', 'test-category'); Logger.info('test message', 'test-category'); expect(consoleSpy).not.toHaveBeenCalled(); consoleSpy.mockRestore(); }); it('should not log debug or info messages when level is WARN', () => { const consoleDebugSpy = jest.spyOn(console, 'debug').mockImplementation(); const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(); Logger.setLevel('warn'); Logger.debug('test message', 'test-category'); Logger.info('test message', 'test-category'); Logger.warn('test message', 'test-category'); expect(consoleDebugSpy).not.toHaveBeenCalled(); expect(consoleInfoSpy).not.toHaveBeenCalled(); consoleDebugSpy.mockRestore(); consoleInfoSpy.mockRestore(); }); it('should only log error messages when level is ERROR', () => { const consoleDebugSpy = jest.spyOn(console, 'debug').mockImplementation(); const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(); const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); Logger.setLevel('error'); Logger.debug('test message', 'test-category'); Logger.info('test message', 'test-category'); Logger.warn('test message', 'test-category'); Logger.error('test message', 'test-category'); expect(consoleDebugSpy).not.toHaveBeenCalled(); expect(consoleInfoSpy).not.toHaveBeenCalled(); expect(consoleWarnSpy).not.toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith('[test-category] ERROR: test message'); consoleDebugSpy.mockRestore(); consoleInfoSpy.mockRestore(); consoleWarnSpy.mockRestore(); consoleErrorSpy.mockRestore(); }); }); });