Changeset 0.28.7 (#193)
This commit is contained in:
238
packages/icd-test-runner/js/crud/knowledge.js
Normal file
238
packages/icd-test-runner/js/crud/knowledge.js
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user