Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed .DS_Store
Binary file not shown.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@ dist/
*.log
.env
.npmrc
.DS_Store
BLUEPRINT.md
implemented.md
coverage/
.vscode/
128 changes: 128 additions & 0 deletions src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ import { OllamaAIClient } from '../ai/ollama';
import { OpenAIAIClient } from '../ai/openai';
import { AnthropicAIClient } from '../ai/anthropic';
import { CustomRestAIClient } from '../ai/custom-rest';
import { AzureOpenAIClient } from '../ai/azure-openai';
import { CohereAIClient } from '../ai/cohere';
import { GoogleGeminiAIClient } from '../ai/google-gemini';
import { GoogleVertexAIClient } from '../ai/google-vertex';
import { AmazonBedrockAIClient } from '../ai/amazon-bedrock';
import { HuggingFaceAIClient } from '../ai/huggingface';
import { GroqAIClient } from '../ai/groq';
import { IBMWatsonxAIClient } from '../ai/ibm-watsonx';
import { OCIGenAIClient } from '../ai/oci-genai';

const { mockStore } = vi.hoisted(() => ({
mockStore: { providers: [] as any[], defaultProvider: undefined as string | undefined },
Expand Down Expand Up @@ -274,6 +283,78 @@ describe('auth command & AI clients', () => {
expect(headers['x-api-key']).toBe('anthropicsecretkey');
},
},
{
backend: 'azure-openai',
addArgs: ['-p', 'azurekey', '-u', 'https://endpoint.openai.azure.com'],
mockJson: { choices: [{ message: { content: 'azure response' } }] },
expectedUrl: 'https://endpoint.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-02-01',
bodyExpectations: (body: any) => expect(body.messages[0].content).toBe('test-prompt'),
headersExpectations: (headers: any) => expect(headers['api-key']).toBe('azurekey'),
},
{
backend: 'cohere',
addArgs: ['-p', 'coherekey'],
mockJson: { text: 'cohere response' },
expectedUrl: 'https://api.cohere.ai/v1/chat',
bodyExpectations: (body: any) => expect(body.message).toBe('test-prompt'),
headersExpectations: (headers: any) => expect(headers['Authorization']).toBe('Bearer coherekey'),
},
{
backend: 'google-gemini',
addArgs: ['-p', 'geminikey'],
mockJson: { candidates: [{ content: { parts: [{ text: 'gemini response' }] } }] },
expectedUrl: 'https://generativelanguage.googleapis.com/v1/models/gemini-pro:generateContent?key=geminikey',
bodyExpectations: (body: any) => expect(body.contents[0].parts[0].text).toBe('test-prompt'),
headersExpectations: () => {},
},
{
backend: 'google-vertex',
addArgs: ['-p', 'vertexkey', '-u', 'https://us-central1-aiplatform.googleapis.com'],
mockJson: { candidates: [{ content: { parts: [{ text: 'vertex response' }] } }] },
expectedUrl: 'https://us-central1-aiplatform.googleapis.com/v1/projects/-/locations/-/publishers/google/models/gemini-pro:generateContent',
bodyExpectations: (body: any) => expect(body.contents[0].parts[0].text).toBe('test-prompt'),
headersExpectations: (headers: any) => expect(headers['Authorization']).toBe('Bearer vertexkey'),
},
{
backend: 'amazon-bedrock',
addArgs: ['-p', 'bedrockkey', '-u', 'https://bedrock.us-east-1.amazonaws.com'],
mockJson: { results: [{ outputText: 'bedrock response' }] },
expectedUrl: 'https://bedrock.us-east-1.amazonaws.com/model/anthropic.claude-v2/invoke',
bodyExpectations: (body: any) => expect(body.inputText).toBe('test-prompt'),
headersExpectations: (headers: any) => expect(headers['Authorization']).toBe('Bearer bedrockkey'),
},
{
backend: 'huggingface',
addArgs: ['-p', 'hfkey'],
mockJson: [{ generated_text: 'hf response' }],
expectedUrl: 'https://api-inference.huggingface.co/models/mistralai/Mixtral-8x7B-Instruct-v0.1',
bodyExpectations: (body: any) => expect(body.inputs).toBe('test-prompt'),
headersExpectations: (headers: any) => expect(headers['Authorization']).toBe('Bearer hfkey'),
},
{
backend: 'groq',
addArgs: ['-p', 'groqkey'],
mockJson: { choices: [{ message: { content: 'groq response' } }] },
expectedUrl: 'https://api.groq.com/openai/v1/chat/completions',
bodyExpectations: (body: any) => expect(body.messages[0].content).toBe('test-prompt'),
headersExpectations: (headers: any) => expect(headers['Authorization']).toBe('Bearer groqkey'),
},
{
backend: 'ibm-watsonx',
addArgs: ['-p', 'watsonkey', '-u', 'https://us-south.ml.cloud.ibm.com', '--custom-header', 'X-Project-Id=watsonproj'],
mockJson: { results: [{ generated_text: 'watson response' }] },
expectedUrl: 'https://us-south.ml.cloud.ibm.com/ml/v1/text/generation?version=2024-03-14',
bodyExpectations: (body: any) => expect(body.input).toBe('test-prompt'),
headersExpectations: (headers: any) => expect(headers['Authorization']).toBe('Bearer watsonkey'),
},
{
backend: 'oci-genai',
addArgs: ['-p', 'ocikey', '-u', 'https://inference.generativeai.us-chicago-1.oci.oraclecloud.com', '--custom-header', 'X-Compartment-Id=ocicompartment'],
mockJson: { inferenceResponse: { generatedTexts: [{ text: 'oci response' }] } },
expectedUrl: 'https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions/generateText',
bodyExpectations: (body: any) => expect(body.inferenceRequest.prompt).toBe('test-prompt'),
headersExpectations: (headers: any) => expect(headers['Authorization']).toBe('Bearer ocikey'),
},
])(
'queries $backend provider client completion with url options: $addArgs',
async ({ backend, addArgs, mockJson, expectedUrl, bodyExpectations, headersExpectations }) => {
Expand Down Expand Up @@ -305,6 +386,44 @@ describe('auth command & AI clients', () => {
await anthropic.configure({ name: 'anthropic', password: 'key' });
expect((anthropic as any).config.baseUrl).toBe('https://api.anthropic.com/v1/messages');
expect((anthropic as any).config.model).toBe('claude-3-5-sonnet-latest');

const azure = new AzureOpenAIClient();
await azure.configure({ name: 'azure-openai', password: 'key', baseUrl: 'http://azure' });
expect((azure as any).model).toBe('gpt-4');

const cohere = new CohereAIClient();
await cohere.configure({ name: 'cohere' });
expect((cohere as any).model).toBe('command-r-plus');

const gemini = new GoogleGeminiAIClient();
await gemini.configure({ name: 'google-gemini' });
expect((gemini as any).model).toBe('gemini-pro');

const vertex = new GoogleVertexAIClient();
await vertex.configure({ name: 'google-vertex' });
expect((vertex as any).model).toBe('gemini-pro');

const bedrock = new AmazonBedrockAIClient();
await bedrock.configure({ name: 'amazon-bedrock' });
expect((bedrock as any).model).toBe('anthropic.claude-v2');

const hf = new HuggingFaceAIClient();
await hf.configure({ name: 'huggingface' });
expect((hf as any).model).toBe('mistralai/Mixtral-8x7B-Instruct-v0.1');

const groq = new GroqAIClient();
await groq.configure({ name: 'groq' });
expect((groq as any).model).toBe('llama3-70b-8192');

const watson = new IBMWatsonxAIClient();
await watson.configure({ name: 'ibm-watsonx' });
expect((watson as any).baseUrl).toBe('https://us-south.ml.cloud.ibm.com');
expect((watson as any).model).toBe('ibm/granite-13b-instruct-v2');

const oci = new OCIGenAIClient();
await oci.configure({ name: 'oci-genai' });
expect((oci as any).baseUrl).toBe('https://inference.generativeai.us-chicago-1.oci.oraclecloud.com');
expect((oci as any).model).toBe('cohere.command-r-plus');
});

// Testing failed completion scenarios
Expand All @@ -313,6 +432,15 @@ describe('auth command & AI clients', () => {
{ ClientClass: AnthropicAIClient, name: 'anthropic', config: { name: 'anthropic', password: 'key' } },
{ ClientClass: OllamaAIClient, name: 'ollama', config: { name: 'ollama' } },
{ ClientClass: CustomRestAIClient, name: 'customrest', config: { name: 'customrest', baseUrl: 'http://test' } },
{ ClientClass: AzureOpenAIClient, name: 'azure-openai', config: { name: 'azure-openai', password: 'key', baseUrl: 'http://test' } },
{ ClientClass: CohereAIClient, name: 'cohere', config: { name: 'cohere', password: 'key' } },
{ ClientClass: GoogleGeminiAIClient, name: 'google-gemini', config: { name: 'google-gemini', password: 'key' } },
{ ClientClass: GoogleVertexAIClient, name: 'google-vertex', config: { name: 'google-vertex', password: 'key', baseUrl: 'http://test' } },
{ ClientClass: AmazonBedrockAIClient, name: 'amazon-bedrock', config: { name: 'amazon-bedrock', password: 'key', baseUrl: 'http://test' } },
{ ClientClass: HuggingFaceAIClient, name: 'huggingface', config: { name: 'huggingface', password: 'key' } },
{ ClientClass: GroqAIClient, name: 'groq', config: { name: 'groq', password: 'key' } },
{ ClientClass: IBMWatsonxAIClient, name: 'ibm-watsonx', config: { name: 'ibm-watsonx', password: 'key', baseUrl: 'http://test' } },
{ ClientClass: OCIGenAIClient, name: 'oci-genai', config: { name: 'oci-genai', password: 'key', baseUrl: 'http://test' } },
])('fails getCompletion on non-ok HTTP responses for $name', async ({ ClientClass, config }) => {
fetchSpy.mockResolvedValueOnce({
ok: false,
Expand Down
83 changes: 83 additions & 0 deletions src/__tests__/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { FileCacheProvider } from '../cache/file-cache';

describe('File Cache Provider', () => {
let cache: FileCacheProvider;
let testDir: string;

beforeEach(async () => {
testDir = path.join(os.tmpdir(), `kdm-cache-test-${Date.now()}`);
cache = new FileCacheProvider();
await cache.configure({ type: 'file', enabled: true, path: testDir });
});

afterEach(() => {
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});

it('stores and loads a cache entry', async () => {
await cache.store('test-key', 'test-value');
const result = await cache.load('test-key');
expect(result).toBe('test-value');
});

it('returns null for non-existent keys', async () => {
const result = await cache.load('missing-key');
expect(result).toBeNull();
});

it('lists stored entries', async () => {
await cache.store('key-1', 'value-1');
await cache.store('key-2', 'value-2');
const entries = await cache.list();
expect(entries).toHaveLength(2);
expect(entries.map((e) => e.key).sort()).toEqual(['key-1', 'key-2']);
});

it('removes a specific entry', async () => {
await cache.store('to-remove', 'data');
await cache.remove('to-remove');
const result = await cache.load('to-remove');
expect(result).toBeNull();
});

it('checks if a key exists', async () => {
await cache.store('exists-key', 'data');
expect(await cache.exists('exists-key')).toBe(true);
expect(await cache.exists('missing-key')).toBe(false);
});

it('purges all entries', async () => {
await cache.store('key-a', 'data-a');
await cache.store('key-b', 'data-b');
await cache.purge();
const entries = await cache.list();
expect(entries).toHaveLength(0);
});

it('handles corrupt cache entries gracefully', async () => {
// Directly write a corrupt file
const filePath = path.join(testDir, 'corrupt-key');
fs.mkdirSync(testDir, { recursive: true });
fs.mkdirSync(filePath);

const result = await cache.load('corrupt-key');
expect(result).toBeNull();
});

it.each([
{ key: 'key-with-data', data: 'hello world', expectedSize: 11 },
{ key: 'empty-data', data: '', expectedSize: 0 },
])('stores entry $key with correct size metadata', async ({ key, data, expectedSize }) => {
await cache.store(key, data);
const entries = await cache.list();
const entry = entries.find((e) => e.key === key);
expect(entry).toBeDefined();
expect(entry?.sizeBytes).toBe(expectedSize);
});
});
38 changes: 18 additions & 20 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ describe('config command', () => {
await program.parseAsync(['node', 'test', 'config', 'setup']);

const webhookPrompt = vi.mocked(tui.input).mock.calls[0][0];
expect(webhookPrompt.validate('not-a-webhook')).toBe('Must be a valid Discord webhook URL (including ID and Token)');
expect(webhookPrompt.validate?.('not-a-webhook')).toBe('Must be a valid Discord webhook URL (including ID and Token)');
});

it('should detect existing config and prompt for reconfiguration — cancel keeps current config', async () => {
Expand Down Expand Up @@ -171,24 +171,22 @@ describe('config command', () => {
expect(guideOrder).toBeLessThan(firstInputOrder());
});

it('should save email_password if provided during email setup', async () => {
vi.mocked(tui.select).mockResolvedValue('email');
vi.mocked(tui.input)
.mockResolvedValueOnce('smtp.gmail.com') // host
.mockResolvedValueOnce('587') // port
.mockResolvedValueOnce('user@test.com') // user
.mockResolvedValueOnce('to@test.com') // to
.mockResolvedValueOnce('pass123'); // password
it('should not save email_password if provided during email setup', async () => {
vi.mocked(tui.select).mockResolvedValue('email');
vi.mocked(tui.input)
.mockResolvedValueOnce('smtp.gmail.com') // host
.mockResolvedValueOnce('587') // port
.mockResolvedValueOnce('user@test.com') // user
.mockResolvedValueOnce('to@test.com') // to
.mockResolvedValueOnce('pass123'); // password

await program.parseAsync(['node', 'test', 'config', 'setup']);
await program.parseAsync(['node', 'test', 'config', 'setup']);

// Find the call that passed 'email_password' instead of assuming index
const passwordCall = vi.mocked(configUtils.setConfig).mock.calls.find(
call => call[0] === 'email_password'
);
expect(passwordCall).toBeDefined();
expect(passwordCall?.[1]).toBe('pass123');
});
const passwordCall = vi.mocked(configUtils.setConfig).mock.calls.find(
(call) => (call[0] as any) === 'email_password',
);
expect(passwordCall).toBeUndefined();
});

it('should require an SMTP host during email setup and validate optional SMTP password', async () => {
vi.mocked(tui.select).mockResolvedValue('email');
Expand All @@ -202,13 +200,13 @@ it('should require an SMTP host during email setup and validate optional SMTP pa
await program.parseAsync(['node', 'test', 'config', 'setup']);

const smtpHostPrompt = vi.mocked(tui.input).mock.calls[0][0];
expect(smtpHostPrompt.validate('')).toBe('Host is required');
expect(smtpHostPrompt.validate?.('')).toBe('Host is required');

// Find the password prompt by looking for the last input call
const passwordPromptIndex = vi.mocked(tui.input).mock.calls.length - 1;
const smtpPasswordPrompt = vi.mocked(tui.input).mock.calls[passwordPromptIndex][0];
expect(smtpPasswordPrompt.validate('')).toBe(true);
expect(smtpPasswordPrompt.validate('anything')).toBe(true);
expect(smtpPasswordPrompt.validate?.('')).toBe(true);
expect(smtpPasswordPrompt.validate?.('anything')).toBe(true);
});

it('should call setConfig on config set', async () => {
Expand Down
Loading