/** * 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; } }; })();