andy-stack_vaultkeeper-ai/__tests__/Services/SettingsService.test.ts
Andrew Beal 28c8ccb44b feat: add quick actions system with provider-aware model settings
Introduce QuickActionsService and QuickAgent for lightweight, single-shot AI
operations. Add a dedicated quickActionModel setting alongside a new top-level
provider setting, with validation ensuring all models match the selected provider
and provider-specific defaults. Refactor FileSystemService to offer both TFile
and path-based overloads (readFile/readFilePath, writeToFile/writeToFilePath,
patchFile/patchFileAtPath). Replace window/document globals with activeWindow/
activeDocument throughout InputService and VaultkeeperAISettingTab for Obsidian
mobile compatibility.
Update linting packages to latest.
2026-04-20 20:20:22 +01:00

524 lines
21 KiB
TypeScript

import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { AIProvider, AIProviderModel, fromModel } from '../../Enums/ApiProvider';
describe('SettingsService', () => {
let settingsService: SettingsService;
let mockPlugin: any;
beforeEach(() => {
// Mock VaultkeeperAIPlugin
mockPlugin = {
saveData: vi.fn().mockResolvedValue(undefined)
};
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin);
});
afterEach(() => {
DeregisterAllServices();
vi.clearAllMocks();
});
describe('Constructor and Initialization', () => {
it('should initialize with default settings when no loaded settings provided', () => {
settingsService = new SettingsService({});
expect(settingsService.settings.firstTimeStart).toBe(true);
expect(settingsService.settings.model).toBe(AIProviderModel.ClaudeHaiku_4_5);
expect(settingsService.settings.apiKeys).toEqual({
claude: '',
openai: '',
gemini: '', mistral: ''
});
expect(settingsService.settings.exclusions).toEqual([]);
expect(settingsService.settings.userInstruction).toBe('');
expect(settingsService.settings.searchResultsLimit).toBe(30);
expect(settingsService.settings.snippetSizeLimit).toBe(100);
});
it('should merge loaded settings with defaults', () => {
const loadedSettings: Partial<IVaultkeeperAISettings> = {
firstTimeStart: false,
model: AIProviderModel.GeminiFlash_2_5,
planningModel: AIProviderModel.GeminiPro_2_5,
apiKeys: {
claude: 'claude-key-123',
openai: 'openai-key-456',
gemini: 'gemini-key-789', mistral: ''
},
searchResultsLimit: 25,
snippetSizeLimit: 200
};
settingsService = new SettingsService(loadedSettings as IVaultkeeperAISettings);
expect(settingsService.settings.firstTimeStart).toBe(false);
expect(settingsService.settings.model).toBe(AIProviderModel.GeminiFlash_2_5);
expect(settingsService.settings.apiKeys.claude).toBe('claude-key-123');
expect(settingsService.settings.apiKeys.openai).toBe('openai-key-456');
expect(settingsService.settings.apiKeys.gemini).toBe('gemini-key-789');
expect(settingsService.settings.searchResultsLimit).toBe(25);
expect(settingsService.settings.snippetSizeLimit).toBe(200);
});
it('should handle partially loaded settings and fill missing properties with defaults', () => {
const loadedSettings: Partial<IVaultkeeperAISettings> = {
model: AIProviderModel.GPT_5_4,
apiKeys: {
claude: '',
openai: 'partial-key',
gemini: '', mistral: ''
}
};
settingsService = new SettingsService(loadedSettings as IVaultkeeperAISettings);
expect(settingsService.settings.firstTimeStart).toBe(true); // Default
expect(settingsService.settings.model).toBe(AIProviderModel.GPT_5_4); // Loaded
expect(settingsService.settings.apiKeys.openai).toBe('partial-key'); // Loaded
expect(settingsService.settings.exclusions).toEqual([]); // Default
expect(settingsService.settings.userInstruction).toBe(''); // Default
expect(settingsService.settings.searchResultsLimit).toBe(30); // Default
expect(settingsService.settings.snippetSizeLimit).toBe(100); // Default
});
});
describe('getApiKeyForProvider', () => {
beforeEach(() => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
model: AIProviderModel.ClaudeSonnet_4_5,
planningModel: AIProviderModel.ClaudeOpus_4,
apiKeys: {
claude: 'claude-api-key',
openai: 'openai-api-key',
gemini: 'gemini-api-key', mistral: ''
},
exclusions: [],
userInstruction: '',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
settingsService = new SettingsService(loadedSettings);
});
it('should return Claude API key for Claude provider', () => {
const key = settingsService.getApiKeyForProvider(AIProvider.Claude);
expect(key).toBe('claude-api-key');
});
it('should return OpenAI API key for OpenAI provider', () => {
const key = settingsService.getApiKeyForProvider(AIProvider.OpenAI);
expect(key).toBe('openai-api-key');
});
it('should return Gemini API key for Gemini provider', () => {
const key = settingsService.getApiKeyForProvider(AIProvider.Gemini);
expect(key).toBe('gemini-api-key');
});
it('should return empty string when no API key is set', () => {
settingsService.settings.apiKeys.claude = '';
const key = settingsService.getApiKeyForProvider(AIProvider.Claude);
expect(key).toBe('');
});
});
describe('getApiKeyForCurrentModel', () => {
it('should return Claude key when current model is Claude', () => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
model: AIProviderModel.ClaudeSonnet_4_5,
planningModel: AIProviderModel.ClaudeOpus_4,
apiKeys: {
claude: 'claude-key',
openai: 'openai-key',
gemini: 'gemini-key', mistral: ''
},
exclusions: [],
userInstruction: '',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
settingsService = new SettingsService(loadedSettings);
const key = settingsService.getApiKeyForCurrentModel();
expect(key).toBe('claude-key');
});
it('should return OpenAI key when current model is GPT', () => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
model: AIProviderModel.GPT_5_4_Mini,
planningModel: AIProviderModel.GPT_5_4,
apiKeys: {
claude: 'claude-key',
openai: 'openai-key',
gemini: 'gemini-key', mistral: ''
},
exclusions: [],
userInstruction: '',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
settingsService = new SettingsService(loadedSettings);
const key = settingsService.getApiKeyForCurrentModel();
expect(key).toBe('openai-key');
});
it('should return Gemini key when current model is Gemini', () => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
model: AIProviderModel.GeminiFlash_2_5,
planningModel: AIProviderModel.GeminiPro_2_5,
apiKeys: {
claude: 'claude-key',
openai: 'openai-key',
gemini: 'gemini-key', mistral: ''
},
exclusions: [],
userInstruction: '',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
settingsService = new SettingsService(loadedSettings);
const key = settingsService.getApiKeyForCurrentModel();
expect(key).toBe('gemini-key');
});
it('should use fromModel to determine provider', () => {
// Test with various Claude models
settingsService = new SettingsService({
model: AIProviderModel.ClaudeOpus_4,
apiKeys: { claude: 'opus-key', openai: '', gemini: '', mistral: '' }
});
expect(settingsService.getApiKeyForCurrentModel()).toBe('opus-key');
// Test with various Gemini models
settingsService = new SettingsService({
model: AIProviderModel.GeminiPro_2_5,
apiKeys: { claude: '', openai: '', gemini: 'pro-key', mistral: '' }
});
expect(settingsService.getApiKeyForCurrentModel()).toBe('pro-key');
// Test with various GPT models
settingsService = new SettingsService({
model: AIProviderModel.GPT_5_4,
apiKeys: { claude: '', openai: 'gpt5-key', gemini: '', mistral: '' }
});
expect(settingsService.getApiKeyForCurrentModel()).toBe('gpt5-key');
});
});
describe('setApiKeyForProvider', () => {
beforeEach(() => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
model: AIProviderModel.ClaudeSonnet_4_5,
planningModel: AIProviderModel.ClaudeOpus_4,
apiKeys: {
claude: '',
openai: '',
gemini: '', mistral: ''
},
exclusions: [],
userInstruction: '',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
settingsService = new SettingsService(loadedSettings);
});
it('should update Claude API key', () => {
settingsService.setApiKeyForProvider(AIProvider.Claude, 'new-claude-key');
expect(settingsService.settings.apiKeys.claude).toBe('new-claude-key');
});
it('should update OpenAI API key', () => {
settingsService.setApiKeyForProvider(AIProvider.OpenAI, 'new-openai-key');
expect(settingsService.settings.apiKeys.openai).toBe('new-openai-key');
});
it('should update Gemini API key', () => {
settingsService.setApiKeyForProvider(AIProvider.Gemini, 'new-gemini-key');
expect(settingsService.settings.apiKeys.gemini).toBe('new-gemini-key');
});
it('should not affect other provider keys when updating one', () => {
settingsService.settings.apiKeys = {
claude: 'existing-claude',
openai: 'existing-openai',
gemini: 'existing-gemini', mistral: ''
};
settingsService.setApiKeyForProvider(AIProvider.Claude, 'updated-claude');
expect(settingsService.settings.apiKeys.claude).toBe('updated-claude');
expect(settingsService.settings.apiKeys.openai).toBe('existing-openai');
expect(settingsService.settings.apiKeys.gemini).toBe('existing-gemini');
});
it('should allow setting empty string as API key', () => {
settingsService.settings.apiKeys.claude = 'some-key';
settingsService.setApiKeyForProvider(AIProvider.Claude, '');
expect(settingsService.settings.apiKeys.claude).toBe('');
});
});
describe('saveSettings', () => {
beforeEach(() => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
model: AIProviderModel.ClaudeSonnet_4_5,
planningModel: AIProviderModel.ClaudeOpus_4,
apiKeys: {
claude: 'test-key',
openai: '',
gemini: '', mistral: ''
},
exclusions: ['node_modules'],
userInstruction: 'Be helpful',
searchResultsLimit: 15,
snippetSizeLimit: 300,
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
settingsService = new SettingsService(loadedSettings);
});
it('should call plugin.saveData with current settings', async () => {
await settingsService.saveSettings();
expect(mockPlugin.saveData).toHaveBeenCalledWith(settingsService.settings);
expect(mockPlugin.saveData).toHaveBeenCalledTimes(1);
});
it('should call plugin.saveData with updated settings after modification', async () => {
settingsService.setApiKeyForProvider(AIProvider.Claude, 'updated-key');
settingsService.settings.userInstruction = 'Updated instruction';
await settingsService.saveSettings();
expect(mockPlugin.saveData).toHaveBeenCalledWith(
expect.objectContaining({
apiKeys: expect.objectContaining({
claude: 'updated-key'
}),
userInstruction: 'Updated instruction'
})
);
});
it('should handle saveData errors gracefully', async () => {
mockPlugin.saveData.mockRejectedValue(new Error('Save failed'));
await expect(settingsService.saveSettings()).rejects.toThrow('Save failed');
});
});
describe('Provider Detection from Model Names', () => {
it('should correctly identify Claude models', () => {
const claudeModels = [
AIProviderModel.ClaudeSonnet_4_5,
AIProviderModel.ClaudeSonnet_4,
AIProviderModel.ClaudeOpus_4,
AIProviderModel.ClaudeHaiku_4_5
];
claudeModels.forEach(model => {
settingsService = new SettingsService({
model,
apiKeys: { claude: 'test-claude', openai: '', gemini: '', mistral: '' }
});
expect(settingsService.getApiKeyForCurrentModel()).toBe('test-claude');
});
});
it('should correctly identify Gemini models', () => {
const geminiModels = [
AIProviderModel.GeminiFlash_2_5,
AIProviderModel.GeminiFlash_2_5_Lite,
AIProviderModel.GeminiPro_2_5
];
geminiModels.forEach(model => {
settingsService = new SettingsService({
model,
apiKeys: { claude: '', openai: '', gemini: 'test-gemini', mistral: '' }
});
expect(settingsService.getApiKeyForCurrentModel()).toBe('test-gemini');
});
});
it('should correctly identify OpenAI models', () => {
const openaiModels = [
AIProviderModel.GPT_5_4_Nano,
AIProviderModel.GPT_5_4_Mini,
AIProviderModel.GPT_5_4,
AIProviderModel.GPT_5_4_Mini
];
openaiModels.forEach(model => {
settingsService = new SettingsService({
model,
apiKeys: { claude: '', openai: 'test-openai', gemini: '', mistral: '' }
});
expect(settingsService.getApiKeyForCurrentModel()).toBe('test-openai');
});
});
});
describe('Settings Immutability and Reference', () => {
it('should maintain reference to settings object', () => {
settingsService = new SettingsService({
model: AIProviderModel.ClaudeSonnet_4_5,
apiKeys: { claude: 'key', openai: '', gemini: '', mistral: '' }
});
const settingsRef = settingsService.settings;
settingsService.setApiKeyForProvider(AIProvider.Claude, 'new-key');
// The reference should still point to the same object
expect(settingsRef.apiKeys.claude).toBe('new-key');
});
it('should allow direct modification of settings properties', () => {
settingsService = new SettingsService({
model: AIProviderModel.ClaudeSonnet_4_5,
apiKeys: { claude: '', openai: '', gemini: '', mistral: '' },
exclusions: []
});
settingsService.settings.exclusions.push('test-exclusion');
expect(settingsService.settings.exclusions).toContain('test-exclusion');
settingsService.settings.userInstruction = 'Direct modification';
expect(settingsService.settings.userInstruction).toBe('Direct modification');
});
});
describe('Search and Snippet Limit Settings', () => {
it('should use default searchResultsLimit when not specified', () => {
settingsService = new SettingsService({});
expect(settingsService.settings.searchResultsLimit).toBe(30);
});
it('should use default snippetSizeLimit when not specified', () => {
settingsService = new SettingsService({});
expect(settingsService.settings.snippetSizeLimit).toBe(100);
});
it('should allow custom searchResultsLimit values', () => {
settingsService = new SettingsService({
searchResultsLimit: 30
});
expect(settingsService.settings.searchResultsLimit).toBe(30);
});
it('should allow custom snippetSizeLimit values', () => {
settingsService = new SettingsService({
snippetSizeLimit: 300
});
expect(settingsService.settings.snippetSizeLimit).toBe(300);
});
it('should allow zero values for searchResultsLimit', () => {
settingsService = new SettingsService({
searchResultsLimit: 0
});
expect(settingsService.settings.searchResultsLimit).toBe(0);
});
it('should allow zero values for snippetSizeLimit', () => {
settingsService = new SettingsService({
snippetSizeLimit: 0
});
expect(settingsService.settings.snippetSizeLimit).toBe(0);
});
it('should allow direct modification of searchResultsLimit', () => {
settingsService = new SettingsService({});
settingsService.settings.searchResultsLimit = 50;
expect(settingsService.settings.searchResultsLimit).toBe(50);
});
it('should allow direct modification of snippetSizeLimit', () => {
settingsService = new SettingsService({});
settingsService.settings.snippetSizeLimit = 500;
expect(settingsService.settings.snippetSizeLimit).toBe(500);
});
it('should persist searchResultsLimit and snippetSizeLimit when saving settings', async () => {
settingsService = new SettingsService({
searchResultsLimit: 20,
snippetSizeLimit: 250
});
await settingsService.saveSettings();
expect(mockPlugin.saveData).toHaveBeenCalledWith(
expect.objectContaining({
searchResultsLimit: 20,
snippetSizeLimit: 250
})
);
});
it('should handle modified limits in saveSettings', async () => {
settingsService = new SettingsService({});
settingsService.settings.searchResultsLimit = 100;
settingsService.settings.snippetSizeLimit = 600;
await settingsService.saveSettings();
expect(mockPlugin.saveData).toHaveBeenCalledWith(
expect.objectContaining({
searchResultsLimit: 100,
snippetSizeLimit: 600
})
);
});
});
});