This commit is contained in:
101
packages/server/tests/kvark/kvark-auth.test.ts
Normal file
101
packages/server/tests/kvark/kvark-auth.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* KVARK Auth — tests with mocked fetch.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { KvarkAuth } from '../../src/kvark/kvark-auth.js';
|
||||
import { KvarkAuthError, KvarkUnavailableError } from '../../src/kvark/kvark-types.js';
|
||||
|
||||
function mockFetch(responses: Array<{ status: number; body: unknown }>): typeof globalThis.fetch {
|
||||
let callIndex = 0;
|
||||
return vi.fn(async () => {
|
||||
const resp = responses[callIndex++] ?? { status: 500, body: { detail: 'No mock response' } };
|
||||
return new Response(JSON.stringify(resp.body), {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
const BASE_CONFIG = { baseUrl: 'http://kvark:8000', identifier: 'admin', password: 'secret' };
|
||||
|
||||
describe('KvarkAuth', () => {
|
||||
it('login calls POST /api/auth/login and returns token', async () => {
|
||||
const fetch = mockFetch([{
|
||||
status: 200,
|
||||
body: { success: true, access_token: 'jwt-123', token_type: 'bearer', user: { id: 1, identifier: 'admin' }, error: null },
|
||||
}]);
|
||||
|
||||
const auth = new KvarkAuth(BASE_CONFIG, fetch);
|
||||
const token = await auth.login();
|
||||
|
||||
expect(token).toBe('jwt-123');
|
||||
expect(auth.hasToken).toBe(true);
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
|
||||
const [url, opts] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe('http://kvark:8000/api/auth/login');
|
||||
expect(opts.method).toBe('POST');
|
||||
expect(JSON.parse(opts.body)).toEqual({ identifier: 'admin', password: 'secret' });
|
||||
});
|
||||
|
||||
it('getToken returns cached token without re-login', async () => {
|
||||
const fetch = mockFetch([{
|
||||
status: 200,
|
||||
body: { success: true, access_token: 'jwt-cached', token_type: 'bearer', user: null, error: null },
|
||||
}]);
|
||||
|
||||
const auth = new KvarkAuth(BASE_CONFIG, fetch);
|
||||
const t1 = await auth.getToken();
|
||||
const t2 = await auth.getToken();
|
||||
|
||||
expect(t1).toBe('jwt-cached');
|
||||
expect(t2).toBe('jwt-cached');
|
||||
expect(fetch).toHaveBeenCalledOnce(); // only one login call
|
||||
});
|
||||
|
||||
it('invalidate clears token, next getToken re-logins', async () => {
|
||||
const fetch = mockFetch([
|
||||
{ status: 200, body: { success: true, access_token: 'token-1', token_type: 'bearer', user: null, error: null } },
|
||||
{ status: 200, body: { success: true, access_token: 'token-2', token_type: 'bearer', user: null, error: null } },
|
||||
]);
|
||||
|
||||
const auth = new KvarkAuth(BASE_CONFIG, fetch);
|
||||
const t1 = await auth.getToken();
|
||||
expect(t1).toBe('token-1');
|
||||
|
||||
auth.invalidate();
|
||||
expect(auth.hasToken).toBe(false);
|
||||
|
||||
const t2 = await auth.getToken();
|
||||
expect(t2).toBe('token-2');
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws KvarkAuthError on login failure', async () => {
|
||||
const fetch = mockFetch([{
|
||||
status: 401,
|
||||
body: { detail: 'Invalid credentials' },
|
||||
}]);
|
||||
|
||||
const auth = new KvarkAuth(BASE_CONFIG, fetch);
|
||||
await expect(auth.login()).rejects.toThrow(KvarkAuthError);
|
||||
});
|
||||
|
||||
it('throws KvarkAuthError when success=false in response', async () => {
|
||||
const fetch = mockFetch([{
|
||||
status: 200,
|
||||
body: { success: false, access_token: null, token_type: 'bearer', user: null, error: 'Account disabled' },
|
||||
}]);
|
||||
|
||||
const auth = new KvarkAuth(BASE_CONFIG, fetch);
|
||||
await expect(auth.login()).rejects.toThrow('Account disabled');
|
||||
});
|
||||
|
||||
it('throws KvarkUnavailableError on network failure', async () => {
|
||||
const fetch = vi.fn(async () => { throw new Error('ECONNREFUSED'); }) as unknown as typeof globalThis.fetch;
|
||||
|
||||
const auth = new KvarkAuth(BASE_CONFIG, fetch);
|
||||
await expect(auth.login()).rejects.toThrow(KvarkUnavailableError);
|
||||
});
|
||||
});
|
||||
279
packages/server/tests/kvark/kvark-client.test.ts
Normal file
279
packages/server/tests/kvark/kvark-client.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* KvarkClient — tests with mocked fetch.
|
||||
* Verifies search, askDocument, ping, error handling, and 401 retry.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { KvarkClient } from '../../src/kvark/kvark-client.js';
|
||||
import {
|
||||
KvarkAuthError,
|
||||
KvarkNotFoundError,
|
||||
KvarkNotImplementedError,
|
||||
KvarkServerError,
|
||||
KvarkUnavailableError,
|
||||
} from '../../src/kvark/kvark-types.js';
|
||||
|
||||
const LOGIN_OK = {
|
||||
status: 200,
|
||||
body: { success: true, access_token: 'test-token', token_type: 'bearer', user: { id: 1, identifier: 'test' }, error: null },
|
||||
};
|
||||
|
||||
const SEARCH_OK = {
|
||||
status: 200,
|
||||
body: {
|
||||
results: [
|
||||
{ document_id: 42, title: 'Project Status', snippet: 'API review postponed...', score: 0.92, document_type: 'pdf' },
|
||||
{ document_id: 108, title: 'Q1 Budget', snippet: 'Budget allocation...', score: 0.87, document_type: 'spreadsheet' },
|
||||
],
|
||||
total: 12,
|
||||
query: 'project status',
|
||||
},
|
||||
};
|
||||
|
||||
const ASK_OK = {
|
||||
status: 200,
|
||||
body: { answer: 'The blocker is identity boundary design', sources: ['doc_42'] },
|
||||
};
|
||||
|
||||
const PING_OK = {
|
||||
status: 200,
|
||||
body: { id: 1, identifier: 'admin', first_name: 'Admin', last_name: 'User', admin: true, developer: false, status: 'Active', created_at: null },
|
||||
};
|
||||
|
||||
type MockResponse = { status: number; body: unknown };
|
||||
|
||||
function createMockFetch(responses: MockResponse[]): typeof globalThis.fetch {
|
||||
let callIndex = 0;
|
||||
return vi.fn(async (url: string) => {
|
||||
// Login calls always return the login response
|
||||
if (url.toString().includes('/api/auth/login')) {
|
||||
return new Response(JSON.stringify(LOGIN_OK.body), { status: LOGIN_OK.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const resp = responses[callIndex++] ?? { status: 500, body: { detail: 'No mock' } };
|
||||
return new Response(JSON.stringify(resp.body), { status: resp.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
const BASE_CONFIG = { baseUrl: 'http://kvark:8000', identifier: 'admin', password: 'secret' };
|
||||
|
||||
describe('KvarkClient', () => {
|
||||
describe('search', () => {
|
||||
it('calls GET /api/search with query params and returns results', async () => {
|
||||
const fetch = createMockFetch([SEARCH_OK]);
|
||||
const client = new KvarkClient(BASE_CONFIG, fetch);
|
||||
|
||||
const result = await client.search('project status', { limit: 10 });
|
||||
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.total).toBe(12);
|
||||
expect(result.query).toBe('project status');
|
||||
expect(result.results[0].document_id).toBe(42);
|
||||
expect(result.results[0].title).toBe('Project Status');
|
||||
|
||||
// Verify URL
|
||||
const calls = (fetch as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const searchCall = calls.find(c => c[0].toString().includes('/api/search'));
|
||||
expect(searchCall).toBeDefined();
|
||||
expect(searchCall![0]).toContain('q=project+status');
|
||||
expect(searchCall![0]).toContain('limit=10');
|
||||
});
|
||||
|
||||
it('passes Authorization Bearer header', async () => {
|
||||
const fetch = createMockFetch([SEARCH_OK]);
|
||||
const client = new KvarkClient(BASE_CONFIG, fetch);
|
||||
|
||||
await client.search('test');
|
||||
|
||||
const calls = (fetch as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const searchCall = calls.find(c => c[0].toString().includes('/api/search'));
|
||||
expect(searchCall![1].headers.Authorization).toBe('Bearer test-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('askDocument', () => {
|
||||
it('calls POST /api/chat/ask with document_id and question', async () => {
|
||||
const fetch = createMockFetch([ASK_OK]);
|
||||
const client = new KvarkClient(BASE_CONFIG, fetch);
|
||||
|
||||
const result = await client.askDocument('42', 'What is the blocker?');
|
||||
|
||||
expect(result.answer).toBe('The blocker is identity boundary design');
|
||||
expect(result.sources).toContain('doc_42');
|
||||
|
||||
const calls = (fetch as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const askCall = calls.find(c => c[0].toString().includes('/api/chat/ask'));
|
||||
expect(askCall).toBeDefined();
|
||||
expect(askCall![1].method).toBe('POST');
|
||||
expect(JSON.parse(askCall![1].body)).toEqual({ document_id: '42', question: 'What is the blocker?' });
|
||||
});
|
||||
|
||||
it('throws KvarkNotImplementedError on 501', async () => {
|
||||
const fetch = createMockFetch([{ status: 501, body: { detail: 'Not implemented yet' } }]);
|
||||
const client = new KvarkClient(BASE_CONFIG, fetch);
|
||||
|
||||
await expect(client.askDocument('42', 'test')).rejects.toThrow(KvarkNotImplementedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ping', () => {
|
||||
it('calls GET /api/auth/me and returns user', async () => {
|
||||
const fetch = createMockFetch([PING_OK]);
|
||||
const client = new KvarkClient(BASE_CONFIG, fetch);
|
||||
|
||||
const user = await client.ping();
|
||||
expect(user.id).toBe(1);
|
||||
expect(user.identifier).toBe('admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws KvarkNotFoundError on 404', async () => {
|
||||
const fetch = createMockFetch([{ status: 404, body: { detail: 'Document not found' } }]);
|
||||
const client = new KvarkClient(BASE_CONFIG, fetch);
|
||||
|
||||
await expect(client.search('missing')).rejects.toThrow(KvarkNotFoundError);
|
||||
});
|
||||
|
||||
it('throws KvarkServerError on 500', async () => {
|
||||
const fetch = createMockFetch([{ status: 500, body: { detail: 'Internal error' } }]);
|
||||
const client = new KvarkClient(BASE_CONFIG, fetch);
|
||||
|
||||
await expect(client.search('broken')).rejects.toThrow(KvarkServerError);
|
||||
});
|
||||
|
||||
it('throws KvarkUnavailableError on network failure', async () => {
|
||||
let loginDone = false;
|
||||
const fetch = vi.fn(async (url: string) => {
|
||||
if (url.toString().includes('/api/auth/login')) {
|
||||
loginDone = true;
|
||||
return new Response(JSON.stringify(LOGIN_OK.body), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
throw new Error('ECONNREFUSED');
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
|
||||
const client = new KvarkClient(BASE_CONFIG, fetch);
|
||||
await expect(client.search('unreachable')).rejects.toThrow(KvarkUnavailableError);
|
||||
});
|
||||
|
||||
it('retries once on 401 with fresh token', async () => {
|
||||
let searchAttempt = 0;
|
||||
const fetch = vi.fn(async (url: string, opts?: RequestInit) => {
|
||||
if (url.toString().includes('/api/auth/login')) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, access_token: `token-${searchAttempt}`, token_type: 'bearer', user: null, error: null }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
searchAttempt++;
|
||||
if (searchAttempt === 1) {
|
||||
// First search attempt → 401
|
||||
return new Response(JSON.stringify({ detail: 'Token expired' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
// Second attempt → success
|
||||
return new Response(JSON.stringify(SEARCH_OK.body), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
|
||||
const client = new KvarkClient(BASE_CONFIG, fetch);
|
||||
const result = await client.search('retry-test');
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(searchAttempt).toBe(2); // first failed, second succeeded
|
||||
});
|
||||
});
|
||||
|
||||
describe('feedback', () => {
|
||||
const FEEDBACK_OK = {
|
||||
status: 200,
|
||||
body: { ok: true, data: { stored: true, feedbackId: 'fb_001' }, error: null },
|
||||
};
|
||||
|
||||
it('calls POST /api/feedback with correct body', async () => {
|
||||
const fetch = createMockFetch([FEEDBACK_OK]);
|
||||
const client = new KvarkClient(BASE_CONFIG, fetch);
|
||||
|
||||
const result = await client.feedback(42, 'project status', true, 'Very helpful');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
// Verify request
|
||||
const calls = (fetch as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const feedbackCall = calls.find(c => c[0].toString().includes('/api/feedback'));
|
||||
expect(feedbackCall).toBeDefined();
|
||||
expect(feedbackCall![1].method).toBe('POST');
|
||||
|
||||
const body = JSON.parse(feedbackCall![1].body);
|
||||
expect(body.feedbackType).toBe('search_result');
|
||||
expect(body.target.documentId).toBe(42);
|
||||
expect(body.signal.rating).toBe('positive');
|
||||
expect(body.signal.label).toBe('useful');
|
||||
expect(body.signal.comment).toBe('Very helpful');
|
||||
expect(body.context.query).toBe('project status');
|
||||
});
|
||||
|
||||
it('sends negative feedback correctly', async () => {
|
||||
const fetch = createMockFetch([FEEDBACK_OK]);
|
||||
const client = new KvarkClient(BASE_CONFIG, fetch);
|
||||
|
||||
await client.feedback(108, 'budget', false);
|
||||
|
||||
const calls = (fetch as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const feedbackCall = calls.find(c => c[0].toString().includes('/api/feedback'));
|
||||
const body = JSON.parse(feedbackCall![1].body);
|
||||
expect(body.signal.rating).toBe('negative');
|
||||
expect(body.signal.label).toBe('not_useful');
|
||||
expect(body.signal.comment).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws on server error', async () => {
|
||||
const fetch = createMockFetch([{ status: 500, body: { detail: 'Internal error' } }]);
|
||||
const client = new KvarkClient(BASE_CONFIG, fetch);
|
||||
|
||||
await expect(client.feedback(42, 'test', true)).rejects.toThrow(KvarkServerError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('action', () => {
|
||||
const ACTION_OK = {
|
||||
status: 200,
|
||||
body: { ok: true, data: { status: 'executed', actionId: 'act_001', auditRef: 'aud_001', result: { entityType: 'jira_comment', entityId: 'comment_987' } }, error: null },
|
||||
};
|
||||
|
||||
it('calls POST /api/actions with correct body shape', async () => {
|
||||
const fetch = createMockFetch([ACTION_OK]);
|
||||
const client = new KvarkClient(BASE_CONFIG, fetch);
|
||||
|
||||
const result = await client.action(
|
||||
'jira.create_comment',
|
||||
{ entityType: 'issue', entityId: 'PROJ-142' },
|
||||
{ comment: 'Follow-up from Waggle' },
|
||||
'User requested',
|
||||
'approval_ref_001',
|
||||
'ws_proj_x',
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.data!.status).toBe('executed');
|
||||
|
||||
const calls = (fetch as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const actionCall = calls.find(c => c[0].toString().includes('/api/actions'));
|
||||
expect(actionCall).toBeDefined();
|
||||
expect(actionCall![1].method).toBe('POST');
|
||||
|
||||
const body = JSON.parse(actionCall![1].body);
|
||||
expect(body.actionType).toBe('jira.create_comment');
|
||||
expect(body.target.entityType).toBe('issue');
|
||||
expect(body.target.entityId).toBe('PROJ-142');
|
||||
expect(body.payload.comment).toBe('Follow-up from Waggle');
|
||||
expect(body.governance.userApproved).toBe(true);
|
||||
expect(body.governance.approvalReference).toBe('approval_ref_001');
|
||||
expect(body.context.reason).toBe('User requested');
|
||||
expect(body.context.workspaceId).toBe('ws_proj_x');
|
||||
});
|
||||
|
||||
it('throws KvarkNotImplementedError on 501', async () => {
|
||||
const fetch = createMockFetch([{ status: 501, body: { detail: 'Not implemented' } }]);
|
||||
const client = new KvarkClient(BASE_CONFIG, fetch);
|
||||
|
||||
await expect(client.action('test', { entityType: 'x', entityId: '1' }, {}, 'test')).rejects.toThrow(KvarkNotImplementedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
84
packages/server/tests/kvark/kvark-config.test.ts
Normal file
84
packages/server/tests/kvark/kvark-config.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* KVARK Config — vault-backed configuration tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getKvarkConfig, type VaultLike } from '../../src/kvark/kvark-config.js';
|
||||
|
||||
function mockVault(entries: Record<string, string>): VaultLike {
|
||||
return {
|
||||
get(name: string) {
|
||||
const value = entries[name];
|
||||
return value !== undefined ? { value } : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('getKvarkConfig', () => {
|
||||
it('returns config from valid vault entry', () => {
|
||||
const vault = mockVault({
|
||||
'kvark:connection': JSON.stringify({
|
||||
baseUrl: 'http://kvark:8000',
|
||||
identifier: 'admin@test.com',
|
||||
password: 'secret123',
|
||||
}),
|
||||
});
|
||||
|
||||
const config = getKvarkConfig(vault);
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.baseUrl).toBe('http://kvark:8000');
|
||||
expect(config!.identifier).toBe('admin@test.com');
|
||||
expect(config!.password).toBe('secret123');
|
||||
});
|
||||
|
||||
it('returns null when vault has no kvark entry', () => {
|
||||
const vault = mockVault({});
|
||||
expect(getKvarkConfig(vault)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when vault entry is invalid JSON', () => {
|
||||
const vault = mockVault({ 'kvark:connection': 'not-json' });
|
||||
expect(getKvarkConfig(vault)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when required fields are missing', () => {
|
||||
const vault = mockVault({
|
||||
'kvark:connection': JSON.stringify({ baseUrl: 'http://kvark:8000' }),
|
||||
});
|
||||
expect(getKvarkConfig(vault)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when fields are empty strings', () => {
|
||||
const vault = mockVault({
|
||||
'kvark:connection': JSON.stringify({ baseUrl: '', identifier: 'admin', password: 'pass' }),
|
||||
});
|
||||
expect(getKvarkConfig(vault)).toBeNull();
|
||||
});
|
||||
|
||||
it('includes optional timeoutMs when present', () => {
|
||||
const vault = mockVault({
|
||||
'kvark:connection': JSON.stringify({
|
||||
baseUrl: 'http://kvark:8000',
|
||||
identifier: 'admin',
|
||||
password: 'pass',
|
||||
timeoutMs: 60000,
|
||||
}),
|
||||
});
|
||||
|
||||
const config = getKvarkConfig(vault);
|
||||
expect(config!.timeoutMs).toBe(60000);
|
||||
});
|
||||
|
||||
it('omits timeoutMs when not in vault entry', () => {
|
||||
const vault = mockVault({
|
||||
'kvark:connection': JSON.stringify({
|
||||
baseUrl: 'http://kvark:8000',
|
||||
identifier: 'admin',
|
||||
password: 'pass',
|
||||
}),
|
||||
});
|
||||
|
||||
const config = getKvarkConfig(vault);
|
||||
expect(config!.timeoutMs).toBeUndefined();
|
||||
});
|
||||
});
|
||||
212
packages/server/tests/kvark/kvark-integration-smoke.test.ts
Normal file
212
packages/server/tests/kvark/kvark-integration-smoke.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* KVARK Integration Smoke — end-to-end mocked path.
|
||||
*
|
||||
* Validates the full chain: vault config → KvarkClient → auth → tools → output.
|
||||
* All HTTP is mocked via injected fetch. No live KVARK dependency.
|
||||
*
|
||||
* This is the Milestone A gate test. When KVARK is live, these same
|
||||
* assertions should pass with real HTTP (replace mockFetch with globalThis.fetch).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { KvarkClient } from '../../src/kvark/kvark-client.js';
|
||||
import { getKvarkConfig, type VaultLike } from '../../src/kvark/kvark-config.js';
|
||||
import { createKvarkTools, parseSearchResults } from '@waggle/agent';
|
||||
import {
|
||||
KvarkUnavailableError,
|
||||
KvarkNotImplementedError,
|
||||
type KvarkClientConfig,
|
||||
} from '../../src/kvark/kvark-types.js';
|
||||
|
||||
// ── Mock KVARK server ────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_USER = { id: 1, identifier: 'admin', first_name: 'Admin', last_name: 'User', admin: true, developer: false, status: 'Active', created_at: null };
|
||||
|
||||
const MOCK_SEARCH_RESULTS = {
|
||||
results: [
|
||||
{ document_id: 42, title: 'Project Status Update', snippet: 'API design review was postponed to next sprint.', score: 0.92, document_type: 'pdf' },
|
||||
{ document_id: 108, title: 'Q1 Budget Analysis', snippet: 'Engineering budget increased by 15%.', score: 0.87, document_type: 'spreadsheet' },
|
||||
],
|
||||
total: 8,
|
||||
query: 'project',
|
||||
};
|
||||
|
||||
const MOCK_ASK_RESPONSE = {
|
||||
answer: 'The blocker is the unresolved identity boundary design.',
|
||||
sources: ['Project Status Update.pdf'],
|
||||
};
|
||||
|
||||
function createMockKvarkServer(): typeof globalThis.fetch {
|
||||
return vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const urlStr = url.toString();
|
||||
|
||||
// POST /api/auth/login
|
||||
if (urlStr.includes('/api/auth/login') && init?.method === 'POST') {
|
||||
return new Response(JSON.stringify({
|
||||
success: true, access_token: 'mock-jwt-token', token_type: 'bearer', user: MOCK_USER, error: null,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
// GET /api/auth/me
|
||||
if (urlStr.includes('/api/auth/me')) {
|
||||
const auth = (init?.headers as Record<string, string>)?.Authorization;
|
||||
if (auth !== 'Bearer mock-jwt-token') {
|
||||
return new Response(JSON.stringify({ detail: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify(MOCK_USER), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
// GET /api/search
|
||||
if (urlStr.includes('/api/search')) {
|
||||
return new Response(JSON.stringify(MOCK_SEARCH_RESULTS), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
// POST /api/chat/ask — simulates 501 (current KVARK reality)
|
||||
if (urlStr.includes('/api/chat/ask') && init?.method === 'POST') {
|
||||
return new Response(JSON.stringify({ detail: 'Not implemented yet' }), { status: 501, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ detail: 'Not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
function createMockKvarkServerWithAsk(): typeof globalThis.fetch {
|
||||
const base = createMockKvarkServer();
|
||||
return vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const urlStr = url.toString();
|
||||
if (urlStr.includes('/api/chat/ask') && init?.method === 'POST') {
|
||||
return new Response(JSON.stringify(MOCK_ASK_RESPONSE), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
return (base as (u: string | URL | Request, i?: RequestInit) => Promise<Response>)(url, init);
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
function unreachableServer(): typeof globalThis.fetch {
|
||||
return vi.fn(async () => { throw new Error('ECONNREFUSED'); }) as unknown as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
const VAULT_CONFIG = { baseUrl: 'http://kvark:8000', identifier: 'admin', password: 'secret' };
|
||||
|
||||
// ── Integration smoke tests ──────────────────────────────────────────────
|
||||
|
||||
describe('KVARK Integration Smoke (Milestone A gate)', () => {
|
||||
|
||||
describe('Full path: vault → client → auth → search → tool output', () => {
|
||||
it('vault config → client creation → successful search', async () => {
|
||||
const vault: VaultLike = {
|
||||
get: (name: string) => name === 'kvark:connection' ? { value: JSON.stringify(VAULT_CONFIG) } : null,
|
||||
};
|
||||
|
||||
// Step 1: Read config from vault
|
||||
const config = getKvarkConfig(vault);
|
||||
expect(config).not.toBeNull();
|
||||
|
||||
// Step 2: Create client with mocked HTTP
|
||||
const client = new KvarkClient(config!, createMockKvarkServer());
|
||||
|
||||
// Step 3: Search (triggers auto-login + search)
|
||||
const results = await client.search('project', { limit: 5 });
|
||||
expect(results.results).toHaveLength(2);
|
||||
expect(results.total).toBe(8);
|
||||
expect(results.results[0].title).toBe('Project Status Update');
|
||||
});
|
||||
|
||||
it('client → tool → formatted agent output', async () => {
|
||||
const client = new KvarkClient(VAULT_CONFIG, createMockKvarkServer());
|
||||
const tools = createKvarkTools({ client });
|
||||
const searchTool = tools.find(t => t.name === 'kvark_search')!;
|
||||
|
||||
const output = await searchTool.execute({ query: 'project' });
|
||||
|
||||
expect(output).toContain('KVARK Search: "project"');
|
||||
expect(output).toContain('2 of 8 results');
|
||||
expect(output).toContain('[pdf] Project Status Update');
|
||||
expect(output).toContain('score: 0.92');
|
||||
expect(output).toContain('ID: 42');
|
||||
});
|
||||
|
||||
it('search results parseable for Milestone B structured consumption', async () => {
|
||||
const client = new KvarkClient(VAULT_CONFIG, createMockKvarkServer());
|
||||
const response = await client.search('project');
|
||||
const structured = parseSearchResults(response);
|
||||
|
||||
expect(structured).toHaveLength(2);
|
||||
expect(structured[0].attribution).toBe('[KVARK: pdf: Project Status Update]');
|
||||
expect(structured[0].documentId).toBe(42);
|
||||
expect(structured[0].score).toBe(0.92);
|
||||
expect(structured[1].attribution).toBe('[KVARK: spreadsheet: Q1 Budget Analysis]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth/me (ping) path', () => {
|
||||
it('ping succeeds with valid token', async () => {
|
||||
const client = new KvarkClient(VAULT_CONFIG, createMockKvarkServer());
|
||||
const user = await client.ping();
|
||||
expect(user.identifier).toBe('admin');
|
||||
expect(user.admin).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('askDocument path', () => {
|
||||
it('returns KvarkNotImplementedError when KVARK returns 501', async () => {
|
||||
const client = new KvarkClient(VAULT_CONFIG, createMockKvarkServer());
|
||||
await expect(client.askDocument('42', 'What is the blocker?')).rejects.toThrow(KvarkNotImplementedError);
|
||||
});
|
||||
|
||||
it('tool handles 501 gracefully with user-facing message', async () => {
|
||||
const client = new KvarkClient(VAULT_CONFIG, createMockKvarkServer());
|
||||
const tools = createKvarkTools({ client });
|
||||
const askTool = tools.find(t => t.name === 'kvark_ask_document')!;
|
||||
|
||||
const output = await askTool.execute({ document_id: '42', question: 'What is the blocker?' });
|
||||
|
||||
expect(output).toContain('not yet available');
|
||||
expect(output).toContain('kvark_search');
|
||||
});
|
||||
|
||||
it('returns real answer when KVARK implements /api/chat/ask', async () => {
|
||||
const client = new KvarkClient(VAULT_CONFIG, createMockKvarkServerWithAsk());
|
||||
const tools = createKvarkTools({ client });
|
||||
const askTool = tools.find(t => t.name === 'kvark_ask_document')!;
|
||||
|
||||
const output = await askTool.execute({ document_id: '42', question: 'What is the blocker?' });
|
||||
|
||||
expect(output).toContain('KVARK Document Answer (doc #42)');
|
||||
expect(output).toContain('identity boundary design');
|
||||
expect(output).toContain('Sources: Project Status Update.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Graceful degradation', () => {
|
||||
it('KVARK unreachable → search tool returns workspace-fallback message', async () => {
|
||||
const client = new KvarkClient(VAULT_CONFIG, unreachableServer());
|
||||
const tools = createKvarkTools({ client });
|
||||
const searchTool = tools.find(t => t.name === 'kvark_search')!;
|
||||
|
||||
const output = await searchTool.execute({ query: 'test' });
|
||||
|
||||
expect(output).toContain('not reachable');
|
||||
expect(output).toContain('workspace memory');
|
||||
});
|
||||
|
||||
it('KVARK unreachable → ask tool returns same fallback', async () => {
|
||||
const client = new KvarkClient(VAULT_CONFIG, unreachableServer());
|
||||
const tools = createKvarkTools({ client });
|
||||
const askTool = tools.find(t => t.name === 'kvark_ask_document')!;
|
||||
|
||||
const output = await askTool.execute({ document_id: '42', question: 'test' });
|
||||
|
||||
expect(output).toContain('not reachable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Solo/Team safe: no KVARK config', () => {
|
||||
it('empty vault → no config → no tools registered', () => {
|
||||
const vault: VaultLike = { get: () => null };
|
||||
const config = getKvarkConfig(vault);
|
||||
expect(config).toBeNull();
|
||||
// In the real wiring (index.ts), this null means the if(kvarkConfig) block is skipped
|
||||
// and allTools remains unchanged — Solo/Team behavior preserved
|
||||
});
|
||||
});
|
||||
});
|
||||
112
packages/server/tests/kvark/kvark-types.test.ts
Normal file
112
packages/server/tests/kvark/kvark-types.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* KVARK Types — shape validation tests.
|
||||
* Verifies TypeScript types match GitHub KVARK Pydantic DTOs.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type {
|
||||
KvarkLoginRequest,
|
||||
KvarkLoginResponse,
|
||||
KvarkUser,
|
||||
KvarkSearchResult,
|
||||
KvarkSearchResponse,
|
||||
KvarkAskRequest,
|
||||
KvarkAskResponse,
|
||||
KvarkChatEvent,
|
||||
KvarkClientConfig,
|
||||
} from '../../src/kvark/kvark-types.js';
|
||||
import {
|
||||
KvarkAuthError,
|
||||
KvarkNotFoundError,
|
||||
KvarkNotImplementedError,
|
||||
KvarkServerError,
|
||||
KvarkUnavailableError,
|
||||
} from '../../src/kvark/kvark-types.js';
|
||||
|
||||
describe('KVARK Types', () => {
|
||||
it('LoginRequest has identifier and password', () => {
|
||||
const req: KvarkLoginRequest = { identifier: 'user@test.com', password: 'secret' };
|
||||
expect(req.identifier).toBe('user@test.com');
|
||||
expect(req.password).toBe('secret');
|
||||
});
|
||||
|
||||
it('LoginResponse matches KVARK shape', () => {
|
||||
const res: KvarkLoginResponse = {
|
||||
success: true,
|
||||
access_token: 'jwt-token-here',
|
||||
token_type: 'bearer',
|
||||
user: { id: 1, identifier: 'test', first_name: 'Test', last_name: 'User', admin: false, developer: false, status: 'Active', created_at: null },
|
||||
error: null,
|
||||
};
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.access_token).toBeTruthy();
|
||||
expect(res.user?.id).toBe(1);
|
||||
});
|
||||
|
||||
it('SearchResult matches KVARK shape', () => {
|
||||
const result: KvarkSearchResult = {
|
||||
document_id: 42,
|
||||
title: 'Project Status',
|
||||
snippet: 'API review postponed...',
|
||||
score: 0.92,
|
||||
document_type: 'pdf',
|
||||
};
|
||||
expect(result.document_id).toBe(42);
|
||||
expect(result.score).toBe(0.92);
|
||||
expect(result.document_type).toBe('pdf');
|
||||
});
|
||||
|
||||
it('SearchResponse wraps results with total and query', () => {
|
||||
const res: KvarkSearchResponse = {
|
||||
results: [{ document_id: 1, title: 'Doc', snippet: 'text', score: 0.8, document_type: null }],
|
||||
total: 15,
|
||||
query: 'test query',
|
||||
};
|
||||
expect(res.results).toHaveLength(1);
|
||||
expect(res.total).toBe(15);
|
||||
expect(res.query).toBe('test query');
|
||||
});
|
||||
|
||||
it('AskRequest and AskResponse match KVARK shape', () => {
|
||||
const req: KvarkAskRequest = { document_id: '42', question: 'What is the blocker?' };
|
||||
const res: KvarkAskResponse = { answer: 'The identity boundary design', sources: ['doc_42'] };
|
||||
expect(req.document_id).toBe('42');
|
||||
expect(res.answer).toBeTruthy();
|
||||
expect(res.sources).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ChatEvent discriminated union covers all types', () => {
|
||||
const events: KvarkChatEvent[] = [
|
||||
{ type: 'status', msg: 'Thinking...' },
|
||||
{ type: 'token', chunk: 'The ' },
|
||||
{ type: 'tool_call', name: 'search', args: { q: 'test' } },
|
||||
{ type: 'tool_result', name: 'search', summary: 'Found 3', duration_ms: 120 },
|
||||
{ type: 'thought', text: 'Analyzing results...' },
|
||||
{ type: 'done', session_id: 1, answer: 'Here is the answer', usage: { input_tokens: 100, output_tokens: 50, latency_ms: 1200 } },
|
||||
{ type: 'error', msg: 'Something went wrong' },
|
||||
];
|
||||
expect(events).toHaveLength(7);
|
||||
expect(events[0].type).toBe('status');
|
||||
expect(events[5].type).toBe('done');
|
||||
});
|
||||
|
||||
it('typed errors have correct names', () => {
|
||||
expect(new KvarkAuthError('test').name).toBe('KvarkAuthError');
|
||||
expect(new KvarkNotFoundError('test').name).toBe('KvarkNotFoundError');
|
||||
expect(new KvarkNotImplementedError('test').name).toBe('KvarkNotImplementedError');
|
||||
expect(new KvarkServerError('test', 500).name).toBe('KvarkServerError');
|
||||
expect(new KvarkServerError('test', 500).statusCode).toBe(500);
|
||||
expect(new KvarkUnavailableError('test').name).toBe('KvarkUnavailableError');
|
||||
});
|
||||
|
||||
it('KvarkClientConfig has required fields', () => {
|
||||
const config: KvarkClientConfig = {
|
||||
baseUrl: 'http://localhost:8000',
|
||||
identifier: 'admin',
|
||||
password: 'pass',
|
||||
};
|
||||
expect(config.baseUrl).toBeTruthy();
|
||||
expect(config.timeoutMs).toBeUndefined(); // optional
|
||||
expect(config.retryOnServerError).toBeUndefined(); // optional
|
||||
});
|
||||
});
|
||||
84
packages/server/tests/kvark/kvark-wiring.test.ts
Normal file
84
packages/server/tests/kvark/kvark-wiring.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* KVARK Wiring — tests that KVARK tools register conditionally.
|
||||
*
|
||||
* Validates:
|
||||
* - getKvarkConfig returns null → no KVARK tools
|
||||
* - getKvarkConfig returns config → createKvarkTools called
|
||||
* - Solo/Team behavior unaffected when vault has no KVARK entry
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getKvarkConfig, type VaultLike } from '../../src/kvark/kvark-config.js';
|
||||
import { createKvarkTools, type KvarkClientLike } from '@waggle/agent';
|
||||
|
||||
function emptyVault(): VaultLike {
|
||||
return { get: () => null };
|
||||
}
|
||||
|
||||
function kvarkVault(): VaultLike {
|
||||
return {
|
||||
get: (name: string) => {
|
||||
if (name === 'kvark:connection') {
|
||||
return { value: JSON.stringify({ baseUrl: 'http://kvark:8000', identifier: 'admin', password: 'pass' }) };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stubClient(): KvarkClientLike {
|
||||
return {
|
||||
search: async () => ({ results: [], total: 0, query: '' }),
|
||||
askDocument: async () => ({ answer: '', sources: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('KVARK Wiring', () => {
|
||||
it('no KVARK config → getKvarkConfig returns null', () => {
|
||||
const config = getKvarkConfig(emptyVault());
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
|
||||
it('with KVARK config → getKvarkConfig returns valid config', () => {
|
||||
const config = getKvarkConfig(kvarkVault());
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.baseUrl).toBe('http://kvark:8000');
|
||||
});
|
||||
|
||||
it('no KVARK config → zero additional tools', () => {
|
||||
const config = getKvarkConfig(emptyVault());
|
||||
// When config is null, no tools created — simulates the if(kvarkConfig) guard
|
||||
const toolCount = config ? createKvarkTools({ client: stubClient() }).length : 0;
|
||||
expect(toolCount).toBe(0);
|
||||
});
|
||||
|
||||
it('with KVARK config → exactly 4 tools registered', () => {
|
||||
const config = getKvarkConfig(kvarkVault());
|
||||
expect(config).not.toBeNull();
|
||||
const tools = createKvarkTools({ client: stubClient() });
|
||||
expect(tools).toHaveLength(4);
|
||||
expect(tools.map(t => t.name)).toEqual(['kvark_search', 'kvark_feedback', 'kvark_action', 'kvark_ask_document']);
|
||||
});
|
||||
|
||||
it('KVARK tools are ToolDefinition-compatible (name, description, parameters, execute)', () => {
|
||||
const tools = createKvarkTools({ client: stubClient() });
|
||||
for (const tool of tools) {
|
||||
expect(typeof tool.name).toBe('string');
|
||||
expect(typeof tool.description).toBe('string');
|
||||
expect(tool.parameters).toBeDefined();
|
||||
expect(typeof tool.execute).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
it('KVARK tools can be appended to an existing tool array', () => {
|
||||
const existingTools = [
|
||||
{ name: 'search_memory', description: 'test', parameters: {}, execute: async () => '' },
|
||||
{ name: 'save_memory', description: 'test', parameters: {}, execute: async () => '' },
|
||||
];
|
||||
const kvarkTools = createKvarkTools({ client: stubClient() });
|
||||
|
||||
const allTools = [...existingTools, ...kvarkTools];
|
||||
expect(allTools).toHaveLength(6);
|
||||
expect(allTools.map(t => t.name)).toEqual(['search_memory', 'save_memory', 'kvark_search', 'kvark_feedback', 'kvark_action', 'kvark_ask_document']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user