239 lines
11 KiB
JavaScript
239 lines
11 KiB
JavaScript
/**
|
|
* ICD Test Runner — CRUD: Knowledge Bases
|
|
* KB lifecycle — create, read, update, documents, search, discoverable,
|
|
* channel binding, scope auth, cross-user isolation, delete.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
var T = window.ICD;
|
|
if (!T) return;
|
|
if (!T.crud) T.crud = {};
|
|
|
|
T.crud.knowledge = async function (testTag) {
|
|
|
|
// ── KB CRUD ──
|
|
var kbId = null;
|
|
await T.test('crud', 'knowledge', 'POST /knowledge-bases (create)', async function () {
|
|
var d = await T.apiPost('/knowledge-bases', {
|
|
name: testTag + '-kb',
|
|
description: 'ICD integration test KB',
|
|
scope: 'personal'
|
|
});
|
|
T.assertShape(d, T.S.kb, 'kb');
|
|
T.assert(d.scope === 'personal', 'scope should be personal');
|
|
T.assert(d.status === 'active', 'status should be active');
|
|
T.assert(d.document_count === 0, 'new KB should have 0 documents');
|
|
T.assert(d.chunk_count === 0, 'new KB should have 0 chunks');
|
|
kbId = d.id;
|
|
T.registerCleanup(function () { if (kbId) return T.safeDelete('/knowledge-bases/' + kbId); });
|
|
});
|
|
|
|
if (!kbId) return;
|
|
|
|
await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id (read)', async function () {
|
|
var d = await T.apiGet('/knowledge-bases/' + kbId);
|
|
T.assertShape(d, T.S.kb, 'kb');
|
|
T.assert(d.id === kbId, 'id mismatch');
|
|
});
|
|
|
|
await T.test('crud', 'knowledge', 'PUT /knowledge-bases/:id (update)', async function () {
|
|
var newName = testTag + '-kb-updated';
|
|
var d = await T.apiPut('/knowledge-bases/' + kbId, { name: newName });
|
|
T.assertShape(d, T.S.kb, 'kb-update');
|
|
T.assert(d.name === newName, 'name should have updated, got: ' + d.name);
|
|
});
|
|
|
|
await T.test('crud', 'knowledge', 'PUT /knowledge-bases/:id (empty body → 400)', async function () {
|
|
var token = await T.getAuthToken();
|
|
var resp = await T.authFetch(token, 'PUT', '/knowledge-bases/' + kbId, {});
|
|
T.assert(resp._status === 400, 'expected 400 for empty update, got ' + resp._status);
|
|
});
|
|
|
|
// ── Documents ──
|
|
await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id/documents (empty)', async function () {
|
|
var d = await T.apiGet('/knowledge-bases/' + kbId + '/documents');
|
|
T.assertHasKey(d, 'data', '/kb-documents');
|
|
T.assert(Array.isArray(d.data), 'data should be array');
|
|
T.assert(d.data.length === 0, 'new KB should have 0 documents');
|
|
});
|
|
|
|
// Document upload → status → delete (requires object store)
|
|
var docId = null;
|
|
var storageOk = false;
|
|
try {
|
|
var ss = await T.apiGet('/admin/storage/status');
|
|
storageOk = ss && ss.configured === true;
|
|
} catch (e) { /* non-admin or storage not configured */ }
|
|
|
|
if (storageOk) {
|
|
await T.test('crud', 'knowledge', 'POST /knowledge-bases/:id/documents (upload)', async function () {
|
|
var blob = new Blob(['# ICD Test Document\n\nThis is test content for KB ingestion.'], { type: 'text/markdown' });
|
|
try {
|
|
var d = await T.apiUpload('/knowledge-bases/' + kbId + '/documents', blob, 'icd-test.md');
|
|
T.assert(d.id, 'expected document id');
|
|
T.assert(d.filename === 'icd-test.md', 'filename mismatch: ' + d.filename);
|
|
T.assert(d.status === 'pending', 'initial status should be pending, got: ' + d.status);
|
|
T.assert(d.kb_id === kbId, 'kb_id mismatch');
|
|
docId = d.id;
|
|
} catch (e) {
|
|
// 412 = no embedding model role configured (infrastructure, not contract bug)
|
|
if (e.message && (e.message.indexOf('412') !== -1 || e.message.indexOf('embedding') !== -1)) return;
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
if (docId) {
|
|
await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id/documents/:docId/status', async function () {
|
|
var d = await T.apiGet('/knowledge-bases/' + kbId + '/documents/' + docId + '/status');
|
|
T.assert(d.id === docId, 'doc id mismatch');
|
|
T.assert(typeof d.status === 'string', 'status should be string');
|
|
T.assert(typeof d.filename === 'string', 'filename should be string');
|
|
});
|
|
|
|
await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id/documents (has doc)', async function () {
|
|
var d = await T.apiGet('/knowledge-bases/' + kbId + '/documents');
|
|
T.assertHasKey(d, 'data', '/kb-documents');
|
|
T.assert(d.data.length >= 1, 'should have at least 1 document');
|
|
});
|
|
|
|
await T.test('crud', 'knowledge', 'DELETE /knowledge-bases/:id/documents/:docId', async function () {
|
|
var token = await T.getAuthToken();
|
|
var resp = await fetch(T.base + '/api/v1/knowledge-bases/' + kbId + '/documents/' + docId, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin'
|
|
});
|
|
T.assert(resp.ok, 'delete doc should succeed, got ' + resp.status);
|
|
var body = await resp.json();
|
|
T.assert(body.deleted === true, 'deleted should be true');
|
|
docId = null;
|
|
});
|
|
|
|
await T.test('crud', 'knowledge', 'GET /documents/:docId/status (deleted → 404)', async function () {
|
|
var token = await T.getAuthToken();
|
|
var resp = await T.authFetch(token, 'GET',
|
|
'/knowledge-bases/' + kbId + '/documents/00000000-0000-0000-0000-000000000099/status');
|
|
T.assert(resp._status === 404, 'expected 404 for deleted doc, got ' + resp._status);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Search ──
|
|
await T.test('crud', 'knowledge', 'POST /knowledge-bases/:id/search', async function () {
|
|
try {
|
|
var d = await T.apiPost('/knowledge-bases/' + kbId + '/search', {
|
|
query: 'test', limit: 5
|
|
});
|
|
// Response envelope uses "data" key (not "results")
|
|
T.assertHasKey(d, 'data', '/kb-search');
|
|
T.assertHasKey(d, 'query', '/kb-search');
|
|
T.assertHasKey(d, 'total', '/kb-search');
|
|
T.assert(Array.isArray(d.data), 'data should be array');
|
|
} catch (e) {
|
|
// "failed to embed query" = no embedding model configured (infrastructure, not contract bug)
|
|
if (e.message && e.message.indexOf('embed') !== -1) return;
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
// ── Rebuild (no documents = 400, or no ingester = 503) ──
|
|
await T.test('crud', 'knowledge', 'POST /knowledge-bases/:id/rebuild (empty → 400|503)', async function () {
|
|
var token = await T.getAuthToken();
|
|
var resp = await T.authFetch(token, 'POST', '/knowledge-bases/' + kbId + '/rebuild', {});
|
|
T.assert(resp._status === 400 || resp._status === 503,
|
|
'expected 400 (no docs) or 503 (no ingester), got ' + resp._status);
|
|
});
|
|
|
|
// ── Channel KB Binding ──
|
|
var bindChannelId = null;
|
|
await T.test('crud', 'knowledge', 'PUT /channels/:id/knowledge-bases (bind)', async function () {
|
|
// Create a temp channel
|
|
var ch = await T.apiPost('/channels', { title: testTag + '-kb-bind' });
|
|
bindChannelId = ch.id;
|
|
T.registerCleanup(function () { if (bindChannelId) return T.safeDelete('/channels/' + bindChannelId); });
|
|
|
|
// Bind KB
|
|
var d = await T.apiPut('/channels/' + bindChannelId + '/knowledge-bases', {
|
|
kb_ids: [kbId]
|
|
});
|
|
T.assertHasKey(d, 'data', '/channel-kb-set');
|
|
});
|
|
|
|
if (bindChannelId) {
|
|
await T.test('crud', 'knowledge', 'GET /channels/:id/knowledge-bases (verify)', async function () {
|
|
var d = await T.apiGet('/channels/' + bindChannelId + '/knowledge-bases');
|
|
T.assertHasKey(d, 'data', '/channel-kbs');
|
|
T.assert(d.data.length === 1, 'should have 1 linked KB, got ' + d.data.length);
|
|
var linked = d.data[0];
|
|
T.assert(linked.kb_id === kbId, 'linked kb_id mismatch');
|
|
T.assert(typeof linked.kb_name === 'string', 'kb_name should be string');
|
|
T.assert(typeof linked.enabled === 'boolean', 'enabled should be boolean');
|
|
});
|
|
|
|
await T.test('crud', 'knowledge', 'PUT /channels/:id/knowledge-bases (unbind)', async function () {
|
|
var d = await T.apiPut('/channels/' + bindChannelId + '/knowledge-bases', { kb_ids: [] });
|
|
T.assertHasKey(d, 'data', '/channel-kb-unbind');
|
|
T.assert(d.data.length === 0, 'should have 0 linked KBs after unbind');
|
|
});
|
|
}
|
|
|
|
// ── Discoverable ──
|
|
await T.test('crud', 'knowledge', 'PUT /knowledge-bases/:id/discoverable (toggle)', async function () {
|
|
var d = await T.apiPut('/knowledge-bases/' + kbId + '/discoverable', { discoverable: false });
|
|
T.assert(d.status === 'ok', 'expected status ok');
|
|
|
|
// Toggle back
|
|
d = await T.apiPut('/knowledge-bases/' + kbId + '/discoverable', { discoverable: true });
|
|
T.assert(d.status === 'ok', 'expected status ok on re-enable');
|
|
});
|
|
|
|
// ── Cross-user isolation (requires fixtures) ──
|
|
var fixtureUser = T.getFixtureUser ? T.getFixtureUser('-user') : null;
|
|
if (fixtureUser && fixtureUser.token) {
|
|
await T.test('crud', 'knowledge', 'isolation: other user cannot access personal KB', async function () {
|
|
var resp = await T.authFetch(fixtureUser.token, 'GET', '/knowledge-bases/' + kbId);
|
|
// Personal KB of admin should be hidden (404) from other user
|
|
T.assert(resp._status === 404, 'expected 404 for other user, got ' + resp._status);
|
|
});
|
|
|
|
await T.test('crud', 'knowledge', 'isolation: other user cannot delete personal KB', async function () {
|
|
var resp = await T.authFetch(fixtureUser.token, 'DELETE', '/knowledge-bases/' + kbId);
|
|
T.assert(resp._status === 404, 'expected 404 for delete by other user, got ' + resp._status);
|
|
});
|
|
|
|
await T.test('crud', 'knowledge', 'scope: non-admin global create → 403', async function () {
|
|
var resp = await T.authFetch(fixtureUser.token, 'POST', '/knowledge-bases', {
|
|
name: testTag + '-sneaky-global', scope: 'global'
|
|
});
|
|
T.assert(resp._status === 403, 'expected 403 for non-admin global KB, got ' + resp._status);
|
|
});
|
|
|
|
await T.test('crud', 'knowledge', 'discoverable: other user cannot toggle', async function () {
|
|
var resp = await T.authFetch(fixtureUser.token, 'PUT',
|
|
'/knowledge-bases/' + kbId + '/discoverable', { discoverable: false });
|
|
T.assert(resp._status === 404 || resp._status === 403,
|
|
'expected 403/404 for non-owner toggle, got ' + resp._status);
|
|
});
|
|
}
|
|
|
|
// ── Delete ──
|
|
await T.test('crud', 'knowledge', 'DELETE /knowledge-bases/:id', async function () {
|
|
await T.safeDelete('/knowledge-bases/' + kbId);
|
|
kbId = null;
|
|
});
|
|
|
|
await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id (deleted → 404)', async function () {
|
|
var token = await T.getAuthToken();
|
|
var resp = await T.authFetch(token, 'GET', '/knowledge-bases/00000000-0000-0000-0000-000000000099');
|
|
T.assert(resp._status === 404, 'expected 404 for nonexistent KB, got ' + resp._status);
|
|
});
|
|
|
|
// Cleanup temp channel
|
|
if (bindChannelId) {
|
|
try { await T.safeDelete('/channels/' + bindChannelId); } catch (e) { /* ok */ }
|
|
bindChannelId = null;
|
|
}
|
|
|
|
};
|
|
})();
|