Files
obsidian_ollama/tests/utils.test.ts
fegger 75fa07f148 ```
Remove unused isValidHttpUrl function and related tests

Remove commented-out model validation regex from constants
```
2026-05-07 00:02:32 +02:00

301 lines
11 KiB
TypeScript
Executable File

/**
* 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();
});
});
});