Feat v0.7.1 surface runner framework (#55)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #55.
This commit is contained in:
@@ -1,517 +0,0 @@
|
||||
/**
|
||||
* ICD Test Runner — CRUD: Channels
|
||||
* Channel lifecycle, DMs, folders, participants, model roster,
|
||||
* KB linking, files, messages, typing, mark-read, user search.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
if (!T.crud) T.crud = {};
|
||||
|
||||
T.crud.channels = async function (testTag) {
|
||||
|
||||
// ── Channels CRUD ──
|
||||
var channelId = null;
|
||||
await T.test('crud', 'channels', 'POST /channels (create)', async function () {
|
||||
var d = await T.apiPost('/channels', {
|
||||
title: testTag + '-channel',
|
||||
type: 'direct',
|
||||
description: 'ICD integration test channel',
|
||||
tags: ['icd-test']
|
||||
});
|
||||
T.assertShape(d, T.S.channelFull, 'created channel');
|
||||
T.assert(d.type === 'direct', 'type should be direct');
|
||||
T.assert(d.is_archived === false, 'should not be archived');
|
||||
channelId = d.id;
|
||||
T.registerCleanup(function () { if (channelId) return T.safeDelete('/channels/' + channelId); });
|
||||
});
|
||||
|
||||
if (channelId) {
|
||||
await T.test('crud', 'channels', 'GET /channels/:id (read)', async function () {
|
||||
var d = await T.apiGet('/channels/' + channelId);
|
||||
T.assertShape(d, T.S.channelFull, 'channel');
|
||||
T.assert(d.id === channelId, 'id mismatch');
|
||||
T.assert(d.title.indexOf(testTag) !== -1, 'title mismatch');
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'PUT /channels/:id (update title+topic+ai_mode)', async function () {
|
||||
var d = await T.apiPut('/channels/' + channelId, {
|
||||
title: testTag + '-updated',
|
||||
topic: 'ICD test topic',
|
||||
ai_mode: 'mention_only'
|
||||
});
|
||||
T.assert(d.title === testTag + '-updated', 'title not updated');
|
||||
T.assert(d.topic === 'ICD test topic', 'topic not set');
|
||||
T.assert(d.ai_mode === 'mention_only', 'ai_mode not set');
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'PUT /channels/:id (restore ai_mode)', async function () {
|
||||
var d = await T.apiPut('/channels/' + channelId, { ai_mode: 'auto' });
|
||||
T.assert(d.ai_mode === 'auto', 'ai_mode not restored');
|
||||
});
|
||||
|
||||
// ── Channel List Query Filters ──
|
||||
await T.test('crud', 'channels', 'GET /channels?type=direct', async function () {
|
||||
var d = await T.apiGet('/channels?type=direct&per_page=5');
|
||||
var arr = d.data || [];
|
||||
T.assert(Array.isArray(arr), 'expected data array');
|
||||
arr.forEach(function (ch) { T.assert(ch.type === 'direct', 'type filter leaked: ' + ch.type); });
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'GET /channels?search=...', async function () {
|
||||
var d = await T.apiGet('/channels?search=' + encodeURIComponent(testTag) + '&per_page=5');
|
||||
var arr = d.data || [];
|
||||
T.assert(Array.isArray(arr), 'expected data array');
|
||||
T.assert(arr.length >= 1, 'search should find our channel');
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'GET /channels?archived=true (empty)', async function () {
|
||||
var d = await T.apiGet('/channels?archived=true&per_page=5');
|
||||
var arr = d.data || [];
|
||||
T.assert(Array.isArray(arr), 'expected data array');
|
||||
// Our channel is not archived, so it shouldn't appear here
|
||||
});
|
||||
|
||||
// ── Message Tree ──
|
||||
await T.test('crud', 'channels', 'GET /channels/:id/path (empty)', async function () {
|
||||
var d = await T.apiGet('/channels/' + channelId + '/path');
|
||||
T.assertHasKey(d, 'messages', '/path');
|
||||
T.assert(Array.isArray(d.messages), 'messages should be array');
|
||||
});
|
||||
|
||||
// ── Participants ──
|
||||
await T.test('crud', 'channels', 'GET /channels/:id/participants (auto owner)', async function () {
|
||||
var d = await T.apiGet('/channels/' + channelId + '/participants');
|
||||
T.assertHasKey(d, 'participants', '/participants');
|
||||
T.assert(Array.isArray(d.participants), 'participants should be array');
|
||||
T.assert(d.participants.length >= 1, 'should have at least owner participant');
|
||||
if (d.participants.length > 0) {
|
||||
T.assertShape(d.participants[0], T.S.participant, 'participant[0]');
|
||||
T.assert(d.participants[0].role === 'owner', 'first participant should be owner');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Participant CRUD (add fixture user, update role, remove) ──
|
||||
var addedParticipantId = null;
|
||||
var fixtureUser = T.getFixtureUser ? T.getFixtureUser('-user') : null;
|
||||
if (fixtureUser && fixtureUser.id) {
|
||||
await T.test('crud', 'channels', 'POST /channels/:id/participants (add user)', async function () {
|
||||
var d = await T.apiPost('/channels/' + channelId + '/participants', {
|
||||
participant_type: 'user',
|
||||
participant_id: fixtureUser.id,
|
||||
role: 'member'
|
||||
});
|
||||
// Response varies — may be participant object or list
|
||||
var p = d.participant || d;
|
||||
T.assert(p.id || p.participants, 'expected participant id or list');
|
||||
if (p.id) addedParticipantId = p.id;
|
||||
// Verify by re-listing
|
||||
var list = await T.apiGet('/channels/' + channelId + '/participants');
|
||||
var found = list.participants.find(function (pp) { return pp.participant_id === fixtureUser.id; });
|
||||
T.assert(found, 'added user not found in participant list');
|
||||
if (!addedParticipantId && found) addedParticipantId = found.id;
|
||||
});
|
||||
|
||||
if (addedParticipantId) {
|
||||
await T.test('crud', 'channels', 'PATCH /channels/:id/participants/:id (update role)', async function () {
|
||||
var d = await T.apiPatch('/channels/' + channelId + '/participants/' + addedParticipantId, {
|
||||
role: 'observer'
|
||||
});
|
||||
T.assert(typeof d === 'object', 'expected response');
|
||||
// Verify
|
||||
var list = await T.apiGet('/channels/' + channelId + '/participants');
|
||||
var found = list.participants.find(function (pp) { return pp.id === addedParticipantId; });
|
||||
T.assert(found && found.role === 'observer', 'role not updated to observer');
|
||||
});
|
||||
|
||||
// ── Multi-user access: participant can GET channel (CS1 fix) ──
|
||||
await T.test('crud', 'channels', 'GET /channels/:id (as participant, CS1 fix)', async function () {
|
||||
var d = await T.authFetch(fixtureUser.token, 'GET', '/channels/' + channelId);
|
||||
T.assert(d._status === 200, 'participant should see channel, got ' + d._status);
|
||||
T.assert(d.id === channelId, 'id mismatch');
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'GET /channels/:id/path (as participant)', async function () {
|
||||
var d = await T.authFetch(fixtureUser.token, 'GET', '/channels/' + channelId + '/path');
|
||||
T.assert(d._status === 200, 'participant should see path, got ' + d._status);
|
||||
T.assertHasKey(d, 'messages', 'participant path');
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'DELETE /channels/:id/participants/:id (remove)', async function () {
|
||||
await T.apiDelete('/channels/' + channelId + '/participants/' + addedParticipantId);
|
||||
var list = await T.apiGet('/channels/' + channelId + '/participants');
|
||||
var found = list.participants.find(function (pp) { return pp.id === addedParticipantId; });
|
||||
T.assert(!found, 'participant should be removed');
|
||||
addedParticipantId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model Roster CRUD ──
|
||||
await T.test('crud', 'channels', 'GET /channels/:id/models (roster)', async function () {
|
||||
var d = await T.apiGet('/channels/' + channelId + '/models');
|
||||
T.assertHasKey(d, 'models', '/channel-models');
|
||||
T.assert(Array.isArray(d.models), 'models should be array');
|
||||
});
|
||||
|
||||
var rosterModelId = null;
|
||||
await T.test('crud', 'channels', 'POST /channels/:id/models (add)', async function () {
|
||||
var d = await T.apiPost('/channels/' + channelId + '/models', {
|
||||
model_id: 'icd-test-model',
|
||||
display_name: 'ICD Test Model',
|
||||
provider_config_id: null
|
||||
});
|
||||
T.assertHasKey(d, 'models', '/models add');
|
||||
T.assert(Array.isArray(d.models), 'models should be array');
|
||||
var added = d.models.find(function (m) { return m.model_id === 'icd-test-model'; });
|
||||
T.assert(added, 'added model not found in roster');
|
||||
rosterModelId = added ? added.id : null;
|
||||
});
|
||||
|
||||
if (rosterModelId) {
|
||||
await T.test('crud', 'channels', 'PATCH /channels/:id/models/:id (update)', async function () {
|
||||
var d = await T.apiPatch('/channels/' + channelId + '/models/' + rosterModelId, {
|
||||
display_name: 'ICD Updated Model'
|
||||
});
|
||||
T.assertHasKey(d, 'models', '/models update');
|
||||
var updated = d.models.find(function (m) { return m.id === rosterModelId; });
|
||||
T.assert(updated && updated.display_name === 'ICD Updated Model', 'display_name not updated');
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'DELETE /channels/:id/models/:id (remove)', async function () {
|
||||
var d = await T.apiDelete('/channels/' + channelId + '/models/' + rosterModelId);
|
||||
T.assertHasKey(d, 'models', '/models delete');
|
||||
var models = d.models || [];
|
||||
var found = models.find(function (m) { return m.id === rosterModelId; });
|
||||
T.assert(!found, 'deleted model still in roster');
|
||||
rosterModelId = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ── KB linking ──
|
||||
await T.test('crud', 'channels', 'GET /channels/:id/knowledge-bases', async function () {
|
||||
var d = await T.apiGet('/channels/' + channelId + '/knowledge-bases');
|
||||
T.assertHasKey(d, 'data', '/channel-kbs');
|
||||
});
|
||||
|
||||
// ── Files ──
|
||||
await T.test('crud', 'channels', 'GET /channels/:id/files', async function () {
|
||||
var d = await T.apiGet('/channels/' + channelId + '/files');
|
||||
T.assertHasKey(d, 'files', '/channel-files');
|
||||
T.assert(Array.isArray(d.files), 'files should be array');
|
||||
});
|
||||
|
||||
// ── File Upload/Download Lifecycle (conditional on storage) ──
|
||||
var storageOk = false;
|
||||
if (T.user.role === 'admin') {
|
||||
try {
|
||||
var ss = await T.apiGet('/admin/storage/status');
|
||||
storageOk = ss && ss.configured === true;
|
||||
} catch (e) { /* not admin or storage disabled */ }
|
||||
}
|
||||
|
||||
var uploadedFileId = null;
|
||||
if (storageOk) {
|
||||
await T.test('crud', 'channels', 'POST /channels/:id/files (upload)', async function () {
|
||||
var blob = new Blob(['ICD test file content'], { type: 'text/plain' });
|
||||
var d = await T.apiUpload('/channels/' + channelId + '/files', blob, 'icd-test.txt');
|
||||
T.assert(d.id, 'expected file id');
|
||||
T.assert(d.filename === 'icd-test.txt', 'filename mismatch');
|
||||
T.assert(d.origin === 'user_upload', 'origin should be user_upload');
|
||||
T.assert(d.content_type === 'text/plain', 'content_type mismatch');
|
||||
uploadedFileId = d.id;
|
||||
T.registerCleanup(function () { if (uploadedFileId) return T.safeDelete('/files/' + uploadedFileId); });
|
||||
});
|
||||
|
||||
if (uploadedFileId) {
|
||||
await T.test('crud', 'channels', 'GET /files/:id (metadata)', async function () {
|
||||
var d = await T.apiGet('/files/' + uploadedFileId);
|
||||
T.assertShape(d, T.S.file, 'file metadata');
|
||||
T.assert(d.id === uploadedFileId, 'id mismatch');
|
||||
T.assert(d.channel_id === channelId, 'channel_id mismatch');
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'GET /files/:id/download', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var resp = await fetch(T.base + '/api/v1/files/' + uploadedFileId + '/download', {
|
||||
headers: { 'Authorization': 'Bearer ' + token },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
T.assert(resp.ok, 'download should succeed, got ' + resp.status);
|
||||
var text = await resp.text();
|
||||
T.assert(text === 'ICD test file content', 'download content mismatch');
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'DELETE /files/:id', async function () {
|
||||
var delId = uploadedFileId;
|
||||
await T.safeDelete('/files/' + delId);
|
||||
uploadedFileId = null;
|
||||
// Verify gone
|
||||
try {
|
||||
await T.apiGet('/files/' + delId);
|
||||
T.assert(false, 'file should be deleted');
|
||||
} catch (e) {
|
||||
T.assert(e.message.indexOf('404') !== -1 || e.message.indexOf('not found') !== -1, 'expected 404');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message Create + Edit + Cursor + Siblings ──
|
||||
var msgId = null;
|
||||
await T.test('crud', 'channels', 'POST /channels/:id/messages (create)', async function () {
|
||||
var d = await T.apiPost('/channels/' + channelId + '/messages', {
|
||||
role: 'user',
|
||||
content: 'ICD integration test message'
|
||||
});
|
||||
T.assertShape(d, T.S.message, 'message');
|
||||
msgId = d.id;
|
||||
});
|
||||
|
||||
if (msgId) {
|
||||
await T.test('crud', 'channels', 'GET /channels/:id/messages (all)', async function () {
|
||||
var d = await T.apiGet('/channels/' + channelId + '/messages');
|
||||
var arr = d.messages || d.data;
|
||||
T.assert(Array.isArray(arr), 'expected array');
|
||||
T.assert(arr.length >= 1, 'expected at least 1 message');
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'GET /channels/:id/messages/:id/siblings', async function () {
|
||||
var d = await T.apiGet('/channels/' + channelId + '/messages/' + msgId + '/siblings');
|
||||
T.assertHasKey(d, 'siblings', '/siblings');
|
||||
T.assert(Array.isArray(d.siblings), 'siblings should be array');
|
||||
});
|
||||
|
||||
// Edit creates sibling
|
||||
var editedMsgId = null;
|
||||
await T.test('crud', 'channels', 'POST /channels/:id/messages/:id/edit', async function () {
|
||||
var d = await T.apiPost('/channels/' + channelId + '/messages/' + msgId + '/edit', {
|
||||
content: 'ICD edited message content'
|
||||
});
|
||||
T.assertShape(d, T.S.message, 'edited message');
|
||||
T.assert(d.id !== msgId, 'edit should create new message id');
|
||||
T.assert(d.content === 'ICD edited message content', 'content mismatch');
|
||||
editedMsgId = d.id;
|
||||
});
|
||||
|
||||
if (editedMsgId) {
|
||||
await T.test('crud', 'channels', 'GET siblings (after edit, expect 2)', async function () {
|
||||
var d = await T.apiGet('/channels/' + channelId + '/messages/' + msgId + '/siblings');
|
||||
T.assert(d.siblings.length >= 2, 'edit should create sibling, got ' + d.siblings.length);
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'PUT /channels/:id/cursor (switch branch)', async function () {
|
||||
var d = await T.apiPut('/channels/' + channelId + '/cursor', {
|
||||
active_leaf_id: msgId
|
||||
});
|
||||
T.assertHasKey(d, 'messages', '/cursor');
|
||||
T.assert(Array.isArray(d.messages), 'cursor response should have messages array');
|
||||
});
|
||||
}
|
||||
|
||||
await T.test('crud', 'channels', 'GET /messages/:id/files', async function () {
|
||||
var d = await T.apiGet('/messages/' + msgId + '/files');
|
||||
T.assertHasKey(d, 'files', '/message-files');
|
||||
T.assert(Array.isArray(d.files), 'files should be array');
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'GET /channels/:id/path (after messages)', async function () {
|
||||
var d = await T.apiGet('/channels/' + channelId + '/path');
|
||||
T.assertHasKey(d, 'messages', '/path');
|
||||
T.assert(d.messages.length >= 1, 'path should have messages');
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'POST /channels/:id/generate-title', async function () {
|
||||
try {
|
||||
var d = await T.apiPost('/channels/' + channelId + '/generate-title', {});
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
} catch (e) {
|
||||
if (e.message && (e.message.indexOf('400') !== -1 || e.message.indexOf('502') !== -1 || e.message.indexOf('model') !== -1))
|
||||
return;
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Typing indicator ──
|
||||
await T.test('crud', 'channels', 'POST /channels/:id/typing', async function () {
|
||||
var d = await T.apiPost('/channels/' + channelId + '/typing', {});
|
||||
T.assert(d.ok === true, 'expected { ok: true }');
|
||||
});
|
||||
|
||||
// ── Mark Read ──
|
||||
await T.test('crud', 'channels', 'POST /channels/:id/mark-read', async function () {
|
||||
var d = await T.apiPost('/channels/' + channelId + '/mark-read', {});
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
});
|
||||
|
||||
// ── Delete ──
|
||||
await T.test('crud', 'channels', 'DELETE /channels/:id', async function () {
|
||||
await T.safeDelete('/channels/' + channelId);
|
||||
channelId = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ── DM Creation + Dedup ──
|
||||
var dmChannelId = null;
|
||||
var dmUser = T.getFixtureUser ? T.getFixtureUser('-user') : null;
|
||||
if (dmUser && dmUser.id) {
|
||||
await T.test('crud', 'channels', 'POST /channels (type=dm, create)', async function () {
|
||||
var d = await T.apiPost('/channels', {
|
||||
title: testTag + '-dm',
|
||||
type: 'dm',
|
||||
participants: [dmUser.id]
|
||||
});
|
||||
T.assert(d.id, 'DM channel should have id');
|
||||
T.assert(d.type === 'dm', 'type should be dm');
|
||||
dmChannelId = d.id;
|
||||
T.registerCleanup(function () { if (dmChannelId) return T.safeDelete('/channels/' + dmChannelId); });
|
||||
});
|
||||
|
||||
if (dmChannelId) {
|
||||
await T.test('crud', 'channels', 'POST /channels (dm dedup → 200)', async function () {
|
||||
var d = await T.apiPost('/channels', {
|
||||
title: testTag + '-dm-dup',
|
||||
type: 'dm',
|
||||
participants: [dmUser.id]
|
||||
});
|
||||
T.assert(d.id === dmChannelId, 'dedup should return same channel id');
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'GET /channels?types=dm (type filter)', async function () {
|
||||
var d = await T.apiGet('/channels?types=dm&per_page=10');
|
||||
var arr = d.data || [];
|
||||
T.assert(Array.isArray(arr), 'expected data array');
|
||||
var found = arr.find(function (ch) { return ch.id === dmChannelId; });
|
||||
T.assert(found, 'DM channel should appear in dm type filter');
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'DELETE /channels/:id (dm cleanup)', async function () {
|
||||
await T.safeDelete('/channels/' + dmChannelId);
|
||||
dmChannelId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channel Types (group, channel) ──
|
||||
var groupChId = null;
|
||||
await T.test('crud', 'channels', 'POST /channels (type=group)', async function () {
|
||||
var d = await T.apiPost('/channels', {
|
||||
title: testTag + '-group',
|
||||
type: 'group',
|
||||
description: 'ICD group test'
|
||||
});
|
||||
T.assertShape(d, T.S.channelFull, 'group channel');
|
||||
T.assert(d.type === 'group', 'type should be group, got: ' + d.type);
|
||||
groupChId = d.id;
|
||||
T.registerCleanup(function () { if (groupChId) return T.safeDelete('/channels/' + groupChId); });
|
||||
});
|
||||
|
||||
var teamChId = null;
|
||||
await T.test('crud', 'channels', 'POST /channels (type=channel)', async function () {
|
||||
var d = await T.apiPost('/channels', {
|
||||
title: testTag + '-team-channel',
|
||||
type: 'channel',
|
||||
description: 'ICD channel test'
|
||||
});
|
||||
T.assertShape(d, T.S.channelFull, 'team channel');
|
||||
T.assert(d.type === 'channel', 'type should be channel, got: ' + d.type);
|
||||
teamChId = d.id;
|
||||
T.registerCleanup(function () { if (teamChId) return T.safeDelete('/channels/' + teamChId); });
|
||||
});
|
||||
|
||||
// ── Multi-type filter ──
|
||||
await T.test('crud', 'channels', 'GET /channels?types=group,channel (multi)', async function () {
|
||||
var d = await T.apiGet('/channels?types=group,channel&per_page=50');
|
||||
var arr = d.data || [];
|
||||
T.assert(Array.isArray(arr), 'expected data array');
|
||||
arr.forEach(function (ch) {
|
||||
T.assert(ch.type === 'group' || ch.type === 'channel',
|
||||
'multi-type filter leaked: ' + ch.type);
|
||||
});
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'GET /channels?types=direct,dm,group,channel (all)', async function () {
|
||||
var d = await T.apiGet('/channels?types=direct,dm,group,channel&per_page=50');
|
||||
var arr = d.data || [];
|
||||
T.assert(Array.isArray(arr), 'expected data array');
|
||||
var types = new Set(arr.map(function (ch) { return ch.type; }));
|
||||
T.assert(types.size <= 4, 'should only contain known types');
|
||||
});
|
||||
|
||||
// Cleanup channel types
|
||||
if (groupChId) {
|
||||
await T.test('crud', 'channels', 'DELETE /channels/:id (group cleanup)', async function () {
|
||||
await T.safeDelete('/channels/' + groupChId);
|
||||
groupChId = null;
|
||||
});
|
||||
}
|
||||
if (teamChId) {
|
||||
await T.test('crud', 'channels', 'DELETE /channels/:id (channel cleanup)', async function () {
|
||||
await T.safeDelete('/channels/' + teamChId);
|
||||
teamChId = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Folders CRUD + Channel Assignment ──
|
||||
var folderId = null;
|
||||
await T.test('crud', 'channels', 'POST /folders (create)', async function () {
|
||||
var d = await T.apiPost('/folders', { name: testTag + '-folder', sort_order: 0 });
|
||||
var folder = d.folder || d.data || d;
|
||||
T.assert(folder.id || folder.ID, 'folder response missing id, got keys: ' + Object.keys(folder).join(', '));
|
||||
folderId = folder.id || folder.ID;
|
||||
T.registerCleanup(function () { if (folderId) return T.safeDelete('/folders/' + folderId); });
|
||||
});
|
||||
|
||||
if (folderId) {
|
||||
await T.test('crud', 'channels', 'PUT /folders/:id (update)', async function () {
|
||||
var d = await T.apiPut('/folders/' + folderId, { name: testTag + '-folder-updated', sort_order: 1 });
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
});
|
||||
|
||||
// Create a channel, assign to folder, verify filter works
|
||||
var folderCh = null;
|
||||
await T.test('crud', 'channels', 'PUT /channels/:id { folder_id } (assign)', async function () {
|
||||
var ch = await T.apiPost('/channels', { title: testTag + '-folder-test', type: 'direct' });
|
||||
folderCh = ch.id;
|
||||
T.registerCleanup(function () { if (folderCh) return T.safeDelete('/channels/' + folderCh); });
|
||||
var d = await T.apiPut('/channels/' + folderCh, { folder_id: folderId });
|
||||
T.assert(d.folder_id === folderId, 'folder_id should be set, got: ' + d.folder_id);
|
||||
});
|
||||
|
||||
if (folderCh) {
|
||||
await T.test('crud', 'channels', 'GET /channels?folder_id=... (filter)', async function () {
|
||||
var d = await T.apiGet('/channels?folder_id=' + folderId + '&per_page=10');
|
||||
var arr = d.data || [];
|
||||
T.assert(Array.isArray(arr), 'expected data array');
|
||||
var found = arr.find(function (ch) { return ch.id === folderCh; });
|
||||
T.assert(found, 'channel should appear in folder_id filter');
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'PUT /channels/:id { folder_id: "" } (unbind)', async function () {
|
||||
var d = await T.apiPut('/channels/' + folderCh, { folder_id: '' });
|
||||
T.assert(!d.folder_id, 'folder_id should be cleared');
|
||||
});
|
||||
|
||||
await T.test('crud', 'channels', 'DELETE /channels/:id (folder test cleanup)', async function () {
|
||||
await T.safeDelete('/channels/' + folderCh);
|
||||
folderCh = null;
|
||||
});
|
||||
}
|
||||
|
||||
await T.test('crud', 'channels', 'DELETE /folders/:id', async function () {
|
||||
await T.safeDelete('/folders/' + folderId);
|
||||
folderId = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ── User Search ──
|
||||
await T.test('crud', 'channels', 'GET /users/search', async function () {
|
||||
var d = await T.apiGet('/users/search?q=' + encodeURIComponent(T.user.username.substring(0, 3)));
|
||||
T.assertHasKey(d, 'users', '/users/search');
|
||||
T.assert(Array.isArray(d.users), 'users should be array');
|
||||
});
|
||||
|
||||
};
|
||||
})();
|
||||
@@ -1,238 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* ICD Test Runner — CRUD: Memory
|
||||
* Memory listing, count shape, error paths, admin endpoints.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
if (!T.crud) T.crud = {};
|
||||
|
||||
T.crud.memory = async function (testTag) {
|
||||
|
||||
// ── Memory CRUD ──
|
||||
// No REST POST for memories (created by AI tools / background extraction).
|
||||
// Test error paths, count shape, and admin endpoints.
|
||||
var fakeMemId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
await T.test('crud', 'memory', 'GET /memories (list, envelope shape)', async function () {
|
||||
var d = await T.apiGet('/memories');
|
||||
T.assertHasKey(d, 'data', '/memories');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
// If memories exist, validate shape
|
||||
if (d.data.length > 0) {
|
||||
T.assertShape(d.data[0], T.S.memory, 'memory[0]');
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('crud', 'memory', 'GET /memories?status=pending_review', async function () {
|
||||
var d = await T.apiGet('/memories?status=pending_review');
|
||||
T.assertHasKey(d, 'data', '/memories?status=pending_review');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
await T.test('crud', 'memory', 'GET /memories/count (shape)', async function () {
|
||||
var d = await T.apiGet('/memories/count');
|
||||
T.assertHasKey(d, 'active', '/memories/count');
|
||||
T.assertHasKey(d, 'pending', '/memories/count');
|
||||
T.assert(typeof d.active === 'number', 'active should be number');
|
||||
T.assert(typeof d.pending === 'number', 'pending should be number');
|
||||
T.assert(d.active >= 0, 'active should be >= 0');
|
||||
T.assert(d.pending >= 0, 'pending should be >= 0');
|
||||
});
|
||||
|
||||
await T.test('crud', 'memory', 'PUT /memories/:id (not found → 404)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'PUT', '/memories/' + fakeMemId, { value: 'x' });
|
||||
T.assertStatus(d, 404, 'update non-existent memory');
|
||||
});
|
||||
|
||||
await T.test('crud', 'memory', 'DELETE /memories/:id (not found → 404)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'DELETE', '/memories/' + fakeMemId);
|
||||
T.assertStatus(d, 404, 'delete non-existent memory');
|
||||
});
|
||||
|
||||
await T.test('crud', 'memory', 'POST /memories/:id/approve (not found → 404)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'POST', '/memories/' + fakeMemId + '/approve');
|
||||
T.assertStatus(d, 404, 'approve non-existent memory');
|
||||
});
|
||||
|
||||
await T.test('crud', 'memory', 'POST /memories/:id/reject (not found → 404)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'POST', '/memories/' + fakeMemId + '/reject');
|
||||
T.assertStatus(d, 404, 'reject non-existent memory');
|
||||
});
|
||||
|
||||
if (T.user.role === 'admin') {
|
||||
await T.test('crud', 'memory', 'GET /admin/memories/pending (admin)', async function () {
|
||||
var d = await T.apiGet('/admin/memories/pending');
|
||||
T.assertHasKey(d, 'data', '/admin/memories/pending');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
if (d.data.length > 0) {
|
||||
T.assertShape(d.data[0], T.S.memory, 'pending[0]');
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('crud', 'memory', 'POST /admin/memories/bulk-approve (empty ids)', async function () {
|
||||
var d = await T.apiPost('/admin/memories/bulk-approve', { ids: [] });
|
||||
T.assertHasKey(d, 'approved', 'bulk-approve');
|
||||
T.assert(d.approved === 0, 'empty ids should approve 0');
|
||||
});
|
||||
|
||||
await T.test('crud', 'memory', 'POST /admin/memories/bulk-approve (bad body → 400)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'POST', '/admin/memories/bulk-approve', 'not-json');
|
||||
T.assertStatus(d, 400, 'bad body');
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* ICD Test Runner — CRUD: Models
|
||||
* Model preference lifecycle — hide/unhide, bulk, validation.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
if (!T.crud) T.crud = {};
|
||||
|
||||
T.crud.models = async function (testTag) {
|
||||
|
||||
// ── Model Preferences CRUD ──
|
||||
// Depends on provider setup from smoke tier — needs at least one model in /models/enabled
|
||||
|
||||
await T.test('crud', 'models', 'GET /models/preferences (initially empty for test user)', async function () {
|
||||
var d = await T.apiGet('/models/preferences');
|
||||
T.assertHasKey(d, 'data', '/models/preferences');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
// Find a model to use for preference tests
|
||||
var prefModelId = null;
|
||||
var prefConfigId = null;
|
||||
await T.test('crud', 'models', 'GET /models/enabled (pick model for pref test)', async function () {
|
||||
var d = await T.apiGet('/models/enabled');
|
||||
var models = d.data || [];
|
||||
var catalog = models.filter(function (m) { return !m.is_persona && m.provider_config_id; });
|
||||
if (catalog.length === 0) return; // no catalog models yet — skip pref write tests
|
||||
prefModelId = catalog[0].model_id;
|
||||
prefConfigId = catalog[0].provider_config_id;
|
||||
});
|
||||
|
||||
if (prefModelId && prefConfigId) {
|
||||
await T.test('crud', 'models', 'PUT /models/preferences (hide model)', async function () {
|
||||
var d = await T.apiPut('/models/preferences', {
|
||||
model_id: prefModelId,
|
||||
provider_config_id: prefConfigId,
|
||||
hidden: true
|
||||
});
|
||||
T.assert(d.message === 'preference updated', 'expected success message');
|
||||
});
|
||||
|
||||
await T.test('crud', 'models', 'GET /models/preferences (verify hidden)', async function () {
|
||||
var d = await T.apiGet('/models/preferences');
|
||||
var prefs = d.data || [];
|
||||
var found = prefs.find(function (p) {
|
||||
return p.model_id === prefModelId && p.provider_config_id === prefConfigId;
|
||||
});
|
||||
T.assert(found, 'preference entry should exist after PUT');
|
||||
T.assert(found.hidden === true, 'hidden should be true');
|
||||
T.assertShape(found, T.S.modelPreference, 'preference');
|
||||
});
|
||||
|
||||
await T.test('crud', 'models', 'GET /models/enabled (hidden flag propagated)', async function () {
|
||||
var d = await T.apiGet('/models/enabled');
|
||||
var models = d.data || [];
|
||||
var found = models.find(function (m) {
|
||||
return m.model_id === prefModelId && m.provider_config_id === prefConfigId;
|
||||
});
|
||||
if (found) {
|
||||
T.assert(found.hidden === true, 'model should have hidden=true in /models/enabled');
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('crud', 'models', 'PUT /models/preferences (unhide — upsert)', async function () {
|
||||
var d = await T.apiPut('/models/preferences', {
|
||||
model_id: prefModelId,
|
||||
provider_config_id: prefConfigId,
|
||||
hidden: false
|
||||
});
|
||||
T.assert(d.message === 'preference updated', 'expected success message');
|
||||
});
|
||||
|
||||
await T.test('crud', 'models', 'GET /models/preferences (verify unhidden, no dup)', async function () {
|
||||
var d = await T.apiGet('/models/preferences');
|
||||
var prefs = d.data || [];
|
||||
var matches = prefs.filter(function (p) {
|
||||
return p.model_id === prefModelId && p.provider_config_id === prefConfigId;
|
||||
});
|
||||
T.assert(matches.length === 1, 'upsert should not duplicate: got ' + matches.length);
|
||||
T.assert(matches[0].hidden === false, 'hidden should be false after unhide');
|
||||
});
|
||||
|
||||
await T.test('crud', 'models', 'PUT /models/preferences (missing provider_config_id → 400)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'PUT', '/models/preferences', { model_id: prefModelId, hidden: true });
|
||||
T.assertStatus(d, 400, 'missing provider_config_id');
|
||||
});
|
||||
|
||||
await T.test('crud', 'models', 'PUT /models/preferences (missing model_id → 400)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'PUT', '/models/preferences', { provider_config_id: prefConfigId, hidden: true });
|
||||
T.assertStatus(d, 400, 'missing model_id');
|
||||
});
|
||||
|
||||
await T.test('crud', 'models', 'POST /models/preferences/bulk (hide)', async function () {
|
||||
var d = await T.apiPost('/models/preferences/bulk', {
|
||||
entries: [{ model_id: prefModelId, provider_config_id: prefConfigId }],
|
||||
hidden: true
|
||||
});
|
||||
T.assert(d.message === 'preferences updated', 'expected bulk success message');
|
||||
T.assert(d.count === 1, 'count should be 1');
|
||||
});
|
||||
|
||||
await T.test('crud', 'models', 'POST /models/preferences/bulk (unhide cleanup)', async function () {
|
||||
var d = await T.apiPost('/models/preferences/bulk', {
|
||||
entries: [{ model_id: prefModelId, provider_config_id: prefConfigId }],
|
||||
hidden: false
|
||||
});
|
||||
T.assert(d.message === 'preferences updated', 'expected bulk success message');
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* ICD Test Runner — CRUD: Notes
|
||||
* Note lifecycle — create, read, update, search, backlinks, delete.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
if (!T.crud) T.crud = {};
|
||||
|
||||
T.crud.notes = async function (testTag) {
|
||||
|
||||
// ── Notes CRUD ──
|
||||
var noteId = null;
|
||||
await T.test('crud', 'notes', 'POST /notes (create)', async function () {
|
||||
var d = await T.apiPost('/notes', {
|
||||
title: testTag + '-note',
|
||||
content: '# ICD Test Note\n\nTest content with [[wikilink]].',
|
||||
folder: 'icd-test'
|
||||
});
|
||||
T.assertShape(d, T.S.note, 'note');
|
||||
noteId = d.id;
|
||||
T.registerCleanup(function () { if (noteId) return T.safeDelete('/notes/' + noteId); });
|
||||
});
|
||||
|
||||
if (noteId) {
|
||||
await T.test('crud', 'notes', 'GET /notes/:id (read)', async function () {
|
||||
var d = await T.apiGet('/notes/' + noteId);
|
||||
T.assertShape(d, T.S.note, 'note');
|
||||
T.assert(d.id === noteId, 'id mismatch');
|
||||
T.assert(d.title.indexOf(testTag) !== -1, 'title mismatch');
|
||||
});
|
||||
|
||||
await T.test('crud', 'notes', 'PUT /notes/:id (update)', async function () {
|
||||
var d = await T.apiPut('/notes/' + noteId, {
|
||||
title: testTag + '-note-updated',
|
||||
content: '# Updated\n\nNew content.'
|
||||
});
|
||||
T.assert(d.title === testTag + '-note-updated' || (d.id && d.id === noteId), 'update response');
|
||||
});
|
||||
|
||||
await T.test('crud', 'notes', 'GET /notes/search', async function () {
|
||||
var d = await T.apiGet('/notes/search?q=' + encodeURIComponent(testTag));
|
||||
T.assertHasKey(d, 'data', '/notes/search');
|
||||
});
|
||||
|
||||
await T.test('crud', 'notes', 'GET /notes/search-titles', async function () {
|
||||
var d = await T.apiGet('/notes/search-titles?q=' + encodeURIComponent(testTag));
|
||||
T.assertHasKey(d, 'data', '/notes/search-titles');
|
||||
});
|
||||
|
||||
await T.test('crud', 'notes', 'GET /notes/:id/backlinks', async function () {
|
||||
var d = await T.apiGet('/notes/' + noteId + '/backlinks');
|
||||
T.assertHasKey(d, 'data', '/backlinks');
|
||||
});
|
||||
|
||||
await T.test('crud', 'notes', 'DELETE /notes/:id', async function () {
|
||||
await T.safeDelete('/notes/' + noteId);
|
||||
noteId = null;
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* ICD Test Runner — Observability CRUD Tests (v0.33.0)
|
||||
* Tests /metrics, /api/docs, /admin/dashboard, X-Request-Id, structured logging.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
|
||||
T.crud.observability = async function () {
|
||||
|
||||
// -- GET /metrics (Prometheus endpoint, no auth required) --
|
||||
await T.test('crud', 'observability', 'GET /metrics returns Prometheus text', async function () {
|
||||
var resp = await fetch(T.base + '/metrics');
|
||||
T.assert(resp.ok, 'expected 200 from /metrics, got ' + resp.status);
|
||||
var text = await resp.text();
|
||||
T.assert(text.indexOf('armature_http_requests_total') !== -1, 'expected armature_http_requests_total in /metrics');
|
||||
T.assert(text.indexOf('armature_http_request_duration_seconds') !== -1, 'expected armature_http_request_duration_seconds in /metrics');
|
||||
T.assert(text.indexOf('armature_websocket_connections') !== -1, 'expected armature_websocket_connections in /metrics');
|
||||
});
|
||||
|
||||
// -- GET /api/docs (Swagger UI) --
|
||||
await T.test('crud', 'observability', 'GET /api/docs returns Swagger UI HTML', async function () {
|
||||
var resp = await fetch(T.base + '/api/docs');
|
||||
T.assert(resp.ok, 'expected 200 from /api/docs, got ' + resp.status);
|
||||
var text = await resp.text();
|
||||
T.assert(text.indexOf('swagger-ui') !== -1, 'expected swagger-ui in /api/docs HTML');
|
||||
});
|
||||
|
||||
// -- GET /api/docs/openapi.yaml --
|
||||
await T.test('crud', 'observability', 'GET /api/docs/openapi.yaml returns valid YAML', async function () {
|
||||
var resp = await fetch(T.base + '/api/docs/openapi.yaml');
|
||||
T.assert(resp.ok, 'expected 200 from /api/docs/openapi.yaml, got ' + resp.status);
|
||||
var text = await resp.text();
|
||||
T.assert(text.indexOf('openapi:') !== -1, 'expected openapi: key in YAML');
|
||||
T.assert(text.indexOf('paths:') !== -1, 'expected paths: key in YAML');
|
||||
});
|
||||
|
||||
// -- X-Request-Id header propagation --
|
||||
await T.test('crud', 'observability', 'X-Request-Id header on API responses', async function () {
|
||||
var resp = await fetch(T.base + '/health');
|
||||
T.assert(resp.ok, 'expected 200');
|
||||
var rid = resp.headers.get('X-Request-Id');
|
||||
T.assert(rid, 'expected X-Request-Id header in response');
|
||||
T.assert(rid.length === 36, 'X-Request-Id should be UUID (36 chars), got ' + rid.length);
|
||||
});
|
||||
|
||||
// -- X-Request-Id passthrough (client sends, server echoes) --
|
||||
await T.test('crud', 'observability', 'X-Request-Id passthrough', async function () {
|
||||
var customId = 'test-' + Date.now();
|
||||
var resp = await fetch(T.base + '/health', {
|
||||
headers: { 'X-Request-Id': customId }
|
||||
});
|
||||
T.assert(resp.ok, 'expected 200');
|
||||
var echoed = resp.headers.get('X-Request-Id');
|
||||
T.assert(echoed === customId, 'expected echoed X-Request-Id "' + customId + '", got "' + echoed + '"');
|
||||
});
|
||||
|
||||
// -- GET /admin/dashboard (admin auth required) --
|
||||
await T.test('crud', 'observability', 'GET /admin/dashboard returns dashboard data', async function () {
|
||||
var d = await T.apiGet('/admin/dashboard');
|
||||
T.assertHasKey(d, 'uptime', '/admin/dashboard');
|
||||
T.assertHasKey(d, 'ws_connections', '/admin/dashboard');
|
||||
T.assertHasKey(d, 'provider_health', '/admin/dashboard');
|
||||
T.assert(typeof d.ws_connections === 'number', 'ws_connections should be number');
|
||||
T.assert(typeof d.uptime === 'string', 'uptime should be string');
|
||||
// provider_health can be null or array
|
||||
if (d.provider_health) {
|
||||
T.assert(Array.isArray(d.provider_health), 'provider_health should be array');
|
||||
}
|
||||
});
|
||||
};
|
||||
})();
|
||||
@@ -1,247 +0,0 @@
|
||||
/**
|
||||
* ICD Test Runner — CRUD: Personas
|
||||
* Admin persona lifecycle (create → list → update → tool grants →
|
||||
* KB bindings → delete), personal persona (policy-gated), team persona
|
||||
* (fixture team), persona groups CRUD.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
if (!T.crud) T.crud = {};
|
||||
|
||||
T.crud.personas = async function (testTag) {
|
||||
|
||||
// ── Personas CRUD (admin + personal + team + tool grants + groups) ──
|
||||
var personaId = '';
|
||||
var personalPersonaId = '';
|
||||
var teamPersonaId = '';
|
||||
|
||||
// Admin persona CRUD
|
||||
await T.test('crud', 'personas', 'POST /admin/personas (create global)', async function () {
|
||||
var d = await T.apiPost('/admin/personas', {
|
||||
name: 'ICD Test Persona', base_model_id: 'test-model',
|
||||
system_prompt: 'You are a test persona.', description: 'Created by ICD test runner'
|
||||
});
|
||||
T.assert(d.id, 'persona should have id');
|
||||
T.assert(d.scope === 'global', 'admin persona scope should be global');
|
||||
T.assert(d.handle, 'handle should be auto-generated');
|
||||
personaId = d.id;
|
||||
});
|
||||
|
||||
if (personaId) {
|
||||
await T.test('crud', 'personas', 'GET /admin/personas (list contains new)', async function () {
|
||||
var d = await T.apiGet('/admin/personas');
|
||||
T.assertHasKey(d, 'data', '/admin/personas');
|
||||
var found = d.data.some(function (p) { return p.id === personaId; });
|
||||
T.assert(found, 'created persona should appear in admin list');
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'PUT /admin/personas/:id (update)', async function () {
|
||||
var d = await T.apiPut('/admin/personas/' + personaId, { name: 'ICD Updated Persona' });
|
||||
T.assert(d.message === 'persona updated', 'expected update confirmation');
|
||||
});
|
||||
|
||||
// Tool grants
|
||||
await T.test('crud', 'personas', 'GET /admin/personas/:id/tool-grants (empty)', async function () {
|
||||
var d = await T.apiGet('/admin/personas/' + personaId + '/tool-grants');
|
||||
T.assertHasKey(d, 'data', 'tool-grants');
|
||||
T.assert(Array.isArray(d.data), 'grants should be array');
|
||||
T.assert(d.data.length === 0, 'initial grants should be empty');
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'PUT /admin/personas/:id/tool-grants (set)', async function () {
|
||||
var d = await T.apiPut('/admin/personas/' + personaId + '/tool-grants', {
|
||||
tool_names: ['web_search', 'calculator']
|
||||
});
|
||||
T.assert(d.status === 'ok', 'expected ok');
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'GET /admin/personas/:id/tool-grants (verify)', async function () {
|
||||
var d = await T.apiGet('/admin/personas/' + personaId + '/tool-grants');
|
||||
T.assert(d.data.length === 2, 'should have 2 grants, got ' + d.data.length);
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'PUT /admin/personas/:id/tool-grants (clear)', async function () {
|
||||
await T.apiPut('/admin/personas/' + personaId + '/tool-grants', { tool_names: [] });
|
||||
var d = await T.apiGet('/admin/personas/' + personaId + '/tool-grants');
|
||||
T.assert(d.data.length === 0, 'grants should be empty after clear');
|
||||
});
|
||||
|
||||
// KB bindings
|
||||
await T.test('crud', 'personas', 'GET /admin/personas/:id/knowledge-bases (empty)', async function () {
|
||||
var d = await T.apiGet('/admin/personas/' + personaId + '/knowledge-bases');
|
||||
T.assertHasKey(d, 'data', 'persona-kbs');
|
||||
T.assert(Array.isArray(d.data), 'kbs should be array');
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'DELETE /admin/personas/:id', async function () {
|
||||
await T.apiDelete('/admin/personas/' + personaId);
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'GET /admin/personas (deleted gone)', async function () {
|
||||
var d = await T.apiGet('/admin/personas');
|
||||
var found = d.data.some(function (p) { return p.id === personaId; });
|
||||
T.assert(!found, 'deleted persona should not appear in list');
|
||||
});
|
||||
}
|
||||
|
||||
// Personal persona (policy-gated)
|
||||
await T.test('crud', 'personas', 'POST /personas (create personal)', async function () {
|
||||
// Ensure policy allows personal personas
|
||||
try { await T.apiPut('/admin/settings/allow_user_personas', { value: 'true' }); } catch (e) { /* may already be set */ }
|
||||
var d = await T.apiPost('/personas', {
|
||||
name: 'ICD Personal Bot', base_model_id: 'test-model',
|
||||
system_prompt: 'Personal test.'
|
||||
});
|
||||
T.assert(d.id, 'personal persona should have id');
|
||||
T.assert(d.scope === 'personal', 'scope should be personal');
|
||||
personalPersonaId = d.id;
|
||||
});
|
||||
|
||||
if (personalPersonaId) {
|
||||
await T.test('crud', 'personas', 'PUT /personas/:id (update personal)', async function () {
|
||||
var d = await T.apiPut('/personas/' + personalPersonaId, { description: 'updated desc' });
|
||||
T.assert(d.message === 'persona updated', 'expected update confirmation');
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'GET /personas (list contains personal)', async function () {
|
||||
var d = await T.apiGet('/personas');
|
||||
T.assertHasKey(d, 'data', '/personas');
|
||||
var found = d.data.some(function (p) { return p.id === personalPersonaId; });
|
||||
T.assert(found, 'personal persona should appear in user list');
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'DELETE /personas/:id (delete personal)', async function () {
|
||||
await T.apiDelete('/personas/' + personalPersonaId);
|
||||
});
|
||||
}
|
||||
|
||||
// Team persona (uses fixture team)
|
||||
if (T.fixtures.team) {
|
||||
var tId = T.fixtures.team.id;
|
||||
|
||||
await T.test('crud', 'personas', 'POST /teams/:teamId/personas (create)', async function () {
|
||||
var d = await T.apiPost('/teams/' + tId + '/personas', {
|
||||
name: 'ICD Team Bot', base_model_id: 'test-model',
|
||||
system_prompt: 'Team test.'
|
||||
});
|
||||
T.assert(d.id, 'team persona should have id');
|
||||
T.assert(d.scope === 'team', 'scope should be team');
|
||||
teamPersonaId = d.id;
|
||||
});
|
||||
|
||||
if (teamPersonaId) {
|
||||
await T.test('crud', 'personas', 'PUT /teams/:teamId/personas/:id (update)', async function () {
|
||||
var d = await T.apiPut('/teams/' + tId + '/personas/' + teamPersonaId, { name: 'ICD Team Bot v2' });
|
||||
T.assert(d.message === 'persona updated', 'expected update confirmation');
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'GET /teams/:teamId/personas/:id/tool-grants', async function () {
|
||||
var d = await T.apiGet('/teams/' + tId + '/personas/' + teamPersonaId + '/tool-grants');
|
||||
T.assertHasKey(d, 'data', 'team-tool-grants');
|
||||
T.assert(Array.isArray(d.data), 'grants should be array');
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'PUT /teams/:teamId/personas/:id/tool-grants (set)', async function () {
|
||||
await T.apiPut('/teams/' + tId + '/personas/' + teamPersonaId + '/tool-grants', {
|
||||
tool_names: ['kb_search']
|
||||
});
|
||||
var d = await T.apiGet('/teams/' + tId + '/personas/' + teamPersonaId + '/tool-grants');
|
||||
T.assert(d.data.length === 1, 'team persona should have 1 grant');
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'GET /teams/:teamId/personas/:id/knowledge-bases', async function () {
|
||||
var d = await T.apiGet('/teams/' + tId + '/personas/' + teamPersonaId + '/knowledge-bases');
|
||||
T.assertHasKey(d, 'data', 'team-persona-kbs');
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'DELETE /teams/:teamId/personas/:id', async function () {
|
||||
await T.apiDelete('/teams/' + tId + '/personas/' + teamPersonaId);
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'GET /teams/:teamId/personas (deleted gone)', async function () {
|
||||
var d = await T.apiGet('/teams/' + tId + '/personas');
|
||||
var found = d.data.some(function (p) { return p.id === teamPersonaId; });
|
||||
T.assert(!found, 'deleted team persona should not appear in list');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Persona Groups CRUD
|
||||
var pgId = '';
|
||||
var pgMemberId = '';
|
||||
|
||||
await T.test('crud', 'personas', 'POST /persona-groups (create)', async function () {
|
||||
var d = await T.apiPost('/persona-groups', { name: 'ICD Test Group', description: 'Test' });
|
||||
T.assert(d.id, 'group should have id');
|
||||
T.assert(d.scope === 'personal', 'group scope should be personal');
|
||||
pgId = d.id;
|
||||
});
|
||||
|
||||
if (pgId) {
|
||||
await T.test('crud', 'personas', 'GET /persona-groups/:id (read)', async function () {
|
||||
var d = await T.apiGet('/persona-groups/' + pgId);
|
||||
T.assert(d.id === pgId, 'group id should match');
|
||||
T.assert(d.name === 'ICD Test Group', 'group name should match');
|
||||
T.assert(Array.isArray(d.members), 'members should be array');
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'PUT /persona-groups/:id (update)', async function () {
|
||||
var d = await T.apiPut('/persona-groups/' + pgId, { name: 'ICD Updated Group' });
|
||||
T.assert(d.ok === true, 'expected ok');
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'GET /persona-groups (list)', async function () {
|
||||
var d = await T.apiGet('/persona-groups');
|
||||
T.assertHasKey(d, 'data', '/persona-groups');
|
||||
var found = d.data.some(function (g) { return g.id === pgId; });
|
||||
T.assert(found, 'created group should appear in list');
|
||||
});
|
||||
|
||||
// Add a member (need a persona — create a temporary one)
|
||||
var tmpPersonaId = '';
|
||||
await T.test('crud', 'personas', 'POST /admin/personas (for group member)', async function () {
|
||||
var d = await T.apiPost('/admin/personas', {
|
||||
name: 'Group Member Bot', base_model_id: 'test-model'
|
||||
});
|
||||
tmpPersonaId = d.id;
|
||||
});
|
||||
|
||||
if (tmpPersonaId) {
|
||||
await T.test('crud', 'personas', 'POST /persona-groups/:id/members (add)', async function () {
|
||||
var d = await T.apiPost('/persona-groups/' + pgId + '/members', {
|
||||
persona_id: tmpPersonaId, is_leader: true
|
||||
});
|
||||
T.assert(d.ok === true, 'expected ok');
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'GET /persona-groups/:id (with member)', async function () {
|
||||
var d = await T.apiGet('/persona-groups/' + pgId);
|
||||
T.assert(d.members.length === 1, 'should have 1 member');
|
||||
T.assert(d.members[0].is_leader === true, 'member should be leader');
|
||||
pgMemberId = d.members[0].id;
|
||||
});
|
||||
|
||||
if (pgMemberId) {
|
||||
await T.test('crud', 'personas', 'DELETE /persona-groups/:id/members/:mid', async function () {
|
||||
await T.apiDelete('/persona-groups/' + pgId + '/members/' + pgMemberId);
|
||||
});
|
||||
|
||||
await T.test('crud', 'personas', 'GET /persona-groups/:id (member removed)', async function () {
|
||||
var d = await T.apiGet('/persona-groups/' + pgId);
|
||||
T.assert(d.members.length === 0, 'should have 0 members after removal');
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup temp persona
|
||||
try { await T.apiDelete('/admin/personas/' + tmpPersonaId); } catch (e) { /* ok */ }
|
||||
}
|
||||
|
||||
await T.test('crud', 'personas', 'DELETE /persona-groups/:id', async function () {
|
||||
await T.apiDelete('/persona-groups/' + pgId);
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* ICD Test Runner — CRUD: Projects
|
||||
* Full project lifecycle — CRUD, channel/KB/note associations, files,
|
||||
* isolation, and admin list.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
if (!T.crud) T.crud = {};
|
||||
|
||||
T.crud.projects = async function (testTag) {
|
||||
|
||||
// ── Create ──
|
||||
var projectId = null;
|
||||
await T.test('crud', 'projects', 'POST /projects (create, 201 + shape)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'POST', '/projects', {
|
||||
name: testTag + '-project',
|
||||
description: 'ICD integration test project',
|
||||
color: '#3b82f6',
|
||||
icon: 'folder'
|
||||
});
|
||||
T.assertStatus(d, 201, 'POST /projects');
|
||||
T.assertShape(d, T.S.project, 'project');
|
||||
T.assert(d.scope === 'personal', 'scope forced to personal, got ' + d.scope);
|
||||
T.assert(typeof d.owner_id === 'string' && d.owner_id.length > 0, 'owner_id must be set');
|
||||
projectId = d.id;
|
||||
T.registerCleanup(function () { if (projectId) return T.safeDelete('/projects/' + projectId); });
|
||||
});
|
||||
|
||||
if (!projectId) return;
|
||||
|
||||
// ── Read ──
|
||||
await T.test('crud', 'projects', 'GET /projects/:id (read, shape)', async function () {
|
||||
var d = await T.apiGet('/projects/' + projectId);
|
||||
T.assertShape(d, T.S.project, 'project');
|
||||
T.assert(d.id === projectId, 'id mismatch');
|
||||
T.assert(d.name === testTag + '-project', 'name mismatch');
|
||||
T.assert(d.description === 'ICD integration test project', 'description mismatch');
|
||||
});
|
||||
|
||||
// ── Update ──
|
||||
await T.test('crud', 'projects', 'PUT /projects/:id (update, returns refreshed)', async function () {
|
||||
var d = await T.apiPut('/projects/' + projectId, { name: testTag + '-proj-updated', is_archived: false });
|
||||
T.assertShape(d, T.S.project, 'project');
|
||||
T.assert(d.name === testTag + '-proj-updated', 'name not updated');
|
||||
T.assert(d.id === projectId, 'id changed unexpectedly');
|
||||
});
|
||||
|
||||
// ── List envelope ──
|
||||
await T.test('crud', 'projects', 'GET /projects (list envelope)', async function () {
|
||||
var d = await T.apiGet('/projects');
|
||||
T.assertHasKey(d, 'data', '/projects');
|
||||
T.assert(Array.isArray(d.data), 'data must be array');
|
||||
var found = d.data.some(function (p) { return p.id === projectId; });
|
||||
T.assert(found, 'created project not in list');
|
||||
});
|
||||
|
||||
// ── Channel association ──
|
||||
var channelId = null;
|
||||
await T.test('crud', 'projects', 'POST /projects/:id/channels (add channel)', async function () {
|
||||
// Create a scratch channel for association testing
|
||||
var ch = await T.apiPost('/channels', { title: testTag + '-proj-ch', type: 'direct' });
|
||||
channelId = ch.id;
|
||||
T.registerCleanup(function () { if (channelId) return T.safeDelete('/channels/' + channelId); });
|
||||
|
||||
var d = await T.apiPost('/projects/' + projectId + '/channels', { channel_id: channelId, position: 0 });
|
||||
T.assert(typeof d === 'object', 'expected object response');
|
||||
});
|
||||
|
||||
await T.test('crud', 'projects', 'GET /projects/:id/channels (list, has added)', async function () {
|
||||
var d = await T.apiGet('/projects/' + projectId + '/channels');
|
||||
T.assertHasKey(d, 'data', '/project-channels');
|
||||
T.assert(Array.isArray(d.data), 'data must be array');
|
||||
T.assert(d.data.length >= 1, 'expected at least 1 channel association');
|
||||
var entry = d.data[0];
|
||||
T.assert(typeof entry.channel_id === 'string', 'missing channel_id');
|
||||
T.assert(typeof entry.project_id === 'string', 'missing project_id');
|
||||
T.assert(typeof entry.added_at === 'string', 'missing added_at');
|
||||
});
|
||||
|
||||
await T.test('crud', 'projects', 'DELETE /projects/:id/channels/:channelId (remove)', async function () {
|
||||
await T.apiDelete('/projects/' + projectId + '/channels/' + channelId);
|
||||
var d = await T.apiGet('/projects/' + projectId + '/channels');
|
||||
T.assert(d.data.length === 0, 'channel should be removed');
|
||||
});
|
||||
|
||||
// ── KB association ──
|
||||
var kbId = null;
|
||||
await T.test('crud', 'projects', 'POST /projects/:id/knowledge-bases (add KB)', async function () {
|
||||
// Create a scratch KB
|
||||
var kb = await T.apiPost('/knowledge-bases', { name: testTag + '-proj-kb', scope: 'personal' });
|
||||
kbId = kb.id;
|
||||
T.registerCleanup(function () { if (kbId) return T.safeDelete('/knowledge-bases/' + kbId); });
|
||||
|
||||
var d = await T.apiPost('/projects/' + projectId + '/knowledge-bases', { kb_id: kbId, auto_search: true });
|
||||
T.assert(typeof d === 'object', 'expected object response');
|
||||
});
|
||||
|
||||
await T.test('crud', 'projects', 'GET /projects/:id/knowledge-bases (list, has added)', async function () {
|
||||
var d = await T.apiGet('/projects/' + projectId + '/knowledge-bases');
|
||||
T.assertHasKey(d, 'data', '/project-kbs');
|
||||
T.assert(d.data.length >= 1, 'expected at least 1 KB association');
|
||||
var entry = d.data[0];
|
||||
T.assert(typeof entry.kb_id === 'string', 'missing kb_id');
|
||||
T.assert(typeof entry.project_id === 'string', 'missing project_id');
|
||||
T.assert(typeof entry.added_at === 'string', 'missing added_at');
|
||||
});
|
||||
|
||||
await T.test('crud', 'projects', 'DELETE /projects/:id/knowledge-bases/:kbId (remove)', async function () {
|
||||
await T.apiDelete('/projects/' + projectId + '/knowledge-bases/' + kbId);
|
||||
var d = await T.apiGet('/projects/' + projectId + '/knowledge-bases');
|
||||
T.assert(d.data.length === 0, 'KB should be removed');
|
||||
});
|
||||
|
||||
// ── Note association ──
|
||||
var noteId = null;
|
||||
await T.test('crud', 'projects', 'POST /projects/:id/notes (add note)', async function () {
|
||||
// Create a scratch note
|
||||
var n = await T.apiPost('/notes', { title: testTag + '-proj-note', content: 'test' });
|
||||
noteId = n.id;
|
||||
T.registerCleanup(function () { if (noteId) return T.safeDelete('/notes/' + noteId); });
|
||||
|
||||
var d = await T.apiPost('/projects/' + projectId + '/notes', { note_id: noteId });
|
||||
T.assert(typeof d === 'object', 'expected object response');
|
||||
});
|
||||
|
||||
await T.test('crud', 'projects', 'GET /projects/:id/notes (list, has added)', async function () {
|
||||
var d = await T.apiGet('/projects/' + projectId + '/notes');
|
||||
T.assertHasKey(d, 'data', '/project-notes');
|
||||
T.assert(d.data.length >= 1, 'expected at least 1 note association');
|
||||
var entry = d.data[0];
|
||||
T.assert(typeof entry.note_id === 'string', 'missing note_id');
|
||||
T.assert(typeof entry.project_id === 'string', 'missing project_id');
|
||||
T.assert(typeof entry.added_at === 'string', 'missing added_at');
|
||||
});
|
||||
|
||||
await T.test('crud', 'projects', 'DELETE /projects/:id/notes/:noteId (remove)', async function () {
|
||||
await T.apiDelete('/projects/' + projectId + '/notes/' + noteId);
|
||||
var d = await T.apiGet('/projects/' + projectId + '/notes');
|
||||
T.assert(d.data.length === 0, 'note should be removed');
|
||||
});
|
||||
|
||||
// ── Files (shape only — no upload in runner) ──
|
||||
await T.test('crud', 'projects', 'GET /projects/:id/files (files key + count)', async function () {
|
||||
var d = await T.apiGet('/projects/' + projectId + '/files');
|
||||
T.assertHasKey(d, 'files', '/project-files');
|
||||
T.assertHasKey(d, 'count', '/project-files');
|
||||
T.assert(Array.isArray(d.files), 'files must be array');
|
||||
T.assert(typeof d.count === 'number', 'count must be number');
|
||||
});
|
||||
|
||||
// ── Isolation ──
|
||||
await T.test('crud', 'projects', 'isolation: other user cannot GET personal project', async function () {
|
||||
var other = T.getFixtureUser('-user');
|
||||
if (!other || !other.token) { T.assert(true, 'skip — no fixture user'); return; }
|
||||
var d = await T.authFetch(other.token, 'GET', '/projects/' + projectId);
|
||||
T.assert(d._status === 404, 'expected 404 for other user, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('crud', 'projects', 'isolation: other user cannot DELETE personal project', async function () {
|
||||
var other = T.getFixtureUser('-user');
|
||||
if (!other || !other.token) { T.assert(true, 'skip — no fixture user'); return; }
|
||||
var d = await T.authFetch(other.token, 'DELETE', '/projects/' + projectId);
|
||||
T.assert(d._status === 404, 'expected 404 for other user DELETE, got ' + d._status);
|
||||
});
|
||||
|
||||
// ── Admin list ──
|
||||
await T.test('crud', 'projects', 'GET /admin/projects (envelope + owner_name)', async function () {
|
||||
var d = await T.apiGet('/admin/projects');
|
||||
T.assertHasKey(d, 'data', '/admin/projects');
|
||||
T.assert(Array.isArray(d.data), 'data must be array');
|
||||
var found = d.data.find(function (p) { return p.id === projectId; });
|
||||
T.assert(found, 'created project not in admin list');
|
||||
T.assert(typeof found.owner_name === 'string', 'admin list must include owner_name');
|
||||
T.assert(typeof found.channel_count === 'number', 'admin list must include channel_count');
|
||||
T.assert(typeof found.kb_count === 'number', 'admin list must include kb_count');
|
||||
T.assert(typeof found.note_count === 'number', 'admin list must include note_count');
|
||||
});
|
||||
|
||||
// ── Delete ──
|
||||
await T.test('crud', 'projects', 'DELETE /projects/:id', async function () {
|
||||
await T.safeDelete('/projects/' + projectId);
|
||||
projectId = null;
|
||||
});
|
||||
|
||||
// Cleanup scratch resources (cleanups run in reverse but let's be explicit)
|
||||
if (channelId) { try { await T.safeDelete('/channels/' + channelId); channelId = null; } catch (e) { /* ok */ } }
|
||||
if (kbId) { try { await T.safeDelete('/knowledge-bases/' + kbId); kbId = null; } catch (e) { /* ok */ } }
|
||||
if (noteId) { try { await T.safeDelete('/notes/' + noteId); noteId = null; } catch (e) { /* ok */ } }
|
||||
|
||||
};
|
||||
})();
|
||||
@@ -1,314 +0,0 @@
|
||||
/**
|
||||
* ICD Test Runner — CRUD: Tasks
|
||||
* Personal task lifecycle, validation (missing name, invalid cron,
|
||||
* workflow type), webhook-triggered tasks, webhook secret auto-gen,
|
||||
* admin task operations.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
if (!T.crud) T.crud = {};
|
||||
|
||||
T.crud.tasks = async function (testTag) {
|
||||
|
||||
// ── Personal Tasks CRUD ──
|
||||
var taskId = null;
|
||||
var taskTriggerToken = null;
|
||||
|
||||
await T.test('crud', 'tasks', 'POST /tasks (create prompt task)', async function () {
|
||||
var d = await T.apiPost('/tasks', {
|
||||
name: testTag + '-task',
|
||||
description: 'ICD integration test task',
|
||||
task_type: 'prompt',
|
||||
schedule: '@daily',
|
||||
user_prompt: 'Summarize test data',
|
||||
model_id: 'test-model',
|
||||
timezone: 'UTC'
|
||||
});
|
||||
T.assertShape(d, T.S.taskFull, 'created task');
|
||||
T.assert(d.task_type === 'prompt', 'task_type should be prompt');
|
||||
T.assert(d.scope === 'personal', 'default scope should be personal');
|
||||
T.assert(d.is_active === true, 'new task should be active');
|
||||
T.assert(d.next_run_at !== null && d.next_run_at !== undefined, 'cron task should have next_run_at');
|
||||
T.assert(d.max_tokens > 0, 'budget defaults should be applied');
|
||||
taskId = d.id;
|
||||
T.registerCleanup(function () { if (taskId) return T.safeDelete('/tasks/' + taskId); });
|
||||
});
|
||||
|
||||
if (taskId) {
|
||||
await T.test('crud', 'tasks', 'GET /tasks/:id (read)', async function () {
|
||||
var d = await T.apiGet('/tasks/' + taskId);
|
||||
T.assertShape(d, T.S.taskFull, 'task');
|
||||
T.assert(d.id === taskId, 'id mismatch');
|
||||
T.assert(d.name.indexOf(testTag) !== -1, 'name mismatch');
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'PUT /tasks/:id (update)', async function () {
|
||||
var d = await T.apiPut('/tasks/' + taskId, {
|
||||
name: testTag + '-task-updated',
|
||||
user_prompt: 'Updated prompt'
|
||||
});
|
||||
T.assert(d.name === testTag + '-task-updated', 'name not updated');
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'GET /tasks (list mine)', async function () {
|
||||
var d = await T.apiGet('/tasks');
|
||||
T.assertHasKey(d, 'data', '/tasks');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
T.assert(d.data.length >= 1, 'should have at least 1 task');
|
||||
var found = d.data.some(function (t) { return t.id === taskId; });
|
||||
T.assert(found, 'created task should be in list');
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'GET /tasks/:id/runs (list runs)', async function () {
|
||||
var d = await T.apiGet('/tasks/' + taskId + '/runs');
|
||||
T.assertHasKey(d, 'data', '/runs');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'POST /tasks/:id/run (run now)', async function () {
|
||||
var d = await T.apiPost('/tasks/' + taskId + '/run', {});
|
||||
T.assertHasKey(d, 'scheduled', '/run');
|
||||
T.assert(d.scheduled === true, 'should be scheduled');
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'POST /tasks/:id/kill (no active run → 404)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'POST', '/tasks/' + taskId + '/kill');
|
||||
// 200 = killed (if run started), 404 = no active run — both acceptable
|
||||
T.assert(d._status === 200 || d._status === 404,
|
||||
'expected 200 or 404, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'PUT /tasks/:id (update schedule)', async function () {
|
||||
var d = await T.apiPut('/tasks/' + taskId, { schedule: '@hourly' });
|
||||
T.assert(d.schedule === '@hourly', 'schedule not updated');
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'PUT /tasks/:id (deactivate)', async function () {
|
||||
var d = await T.apiPut('/tasks/' + taskId, { is_active: false });
|
||||
T.assert(d.is_active === false, 'should be deactivated');
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'DELETE /tasks/:id', async function () {
|
||||
await T.safeDelete('/tasks/' + taskId);
|
||||
taskId = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Task Validation Tests ──
|
||||
await T.test('crud', 'tasks', 'POST /tasks (missing name → 400)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'POST', '/tasks', {
|
||||
task_type: 'prompt', schedule: '@daily',
|
||||
user_prompt: 'x', model_id: 'm'
|
||||
});
|
||||
T.assertStatus(d, 400, 'missing name');
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'POST /tasks (invalid cron → 400)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'POST', '/tasks', {
|
||||
name: testTag + '-bad-cron', task_type: 'prompt',
|
||||
schedule: 'not-a-cron', user_prompt: 'x', model_id: 'm'
|
||||
});
|
||||
T.assertStatus(d, 400, 'invalid cron');
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'POST /tasks (workflow type → 400 not implemented)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'POST', '/tasks', {
|
||||
name: testTag + '-wf', task_type: 'workflow',
|
||||
schedule: '@daily', workflow_id: '00000000-0000-0000-0000-000000000001',
|
||||
model_id: 'm'
|
||||
});
|
||||
T.assertStatus(d, 400, 'workflow type rejected');
|
||||
});
|
||||
|
||||
// ── Webhook-Triggered Task + Trigger Endpoint ──
|
||||
var webhookTaskId = null;
|
||||
var webhookTriggerToken = null;
|
||||
|
||||
await T.test('crud', 'tasks', 'POST /tasks (create webhook task)', async function () {
|
||||
var d = await T.apiPost('/tasks', {
|
||||
name: testTag + '-webhook-task',
|
||||
task_type: 'prompt',
|
||||
schedule: 'webhook',
|
||||
user_prompt: 'Process trigger data',
|
||||
model_id: 'test-model'
|
||||
});
|
||||
T.assertShape(d, T.S.taskFull, 'webhook task');
|
||||
T.assert(d.schedule === 'webhook', 'schedule should be webhook');
|
||||
T.assert(d.trigger_token && d.trigger_token.length > 0, 'webhook task should have trigger_token');
|
||||
T.assert(d.next_run_at === null || d.next_run_at === undefined, 'webhook task should have no next_run_at');
|
||||
webhookTaskId = d.id;
|
||||
webhookTriggerToken = d.trigger_token;
|
||||
T.registerCleanup(function () { if (webhookTaskId) return T.safeDelete('/tasks/' + webhookTaskId); });
|
||||
});
|
||||
|
||||
if (webhookTaskId && webhookTriggerToken) {
|
||||
await T.test('crud', 'tasks', 'POST /hooks/t/:token (fire trigger → 202)', async function () {
|
||||
var d = await T.publicPost('/hooks/t/' + webhookTriggerToken, {
|
||||
build_id: 12345, status: 'failed', repo: 'icd-test'
|
||||
});
|
||||
T.assertStatus(d, 202, 'trigger');
|
||||
T.assert(d.triggered === true, 'should be triggered');
|
||||
T.assert(d.run_id && d.run_id.length > 0, 'should return run_id');
|
||||
T.assert(d.task_id === webhookTaskId, 'task_id should match');
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'POST /hooks/t/:token (duplicate → 409)', async function () {
|
||||
var d = await T.publicPost('/hooks/t/' + webhookTriggerToken, { test: true });
|
||||
T.assertStatus(d, 409, 'duplicate trigger');
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'POST /hooks/t/bad-token (invalid → 404)', async function () {
|
||||
var d = await T.publicPost('/hooks/t/nonexistent-token-value', { test: true });
|
||||
T.assertStatus(d, 404, 'bad token');
|
||||
});
|
||||
|
||||
// Deactivate then trigger → 410
|
||||
await T.test('crud', 'tasks', 'PUT+POST deactivated webhook task → 410', async function () {
|
||||
await T.apiPut('/tasks/' + webhookTaskId, { is_active: false });
|
||||
var d = await T.publicPost('/hooks/t/' + webhookTriggerToken, { test: true });
|
||||
T.assertStatus(d, 410, 'inactive trigger');
|
||||
// Re-activate for cleanup
|
||||
await T.apiPut('/tasks/' + webhookTaskId, { is_active: true });
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'GET /tasks/:id/runs (webhook task has queued run)', async function () {
|
||||
var d = await T.apiGet('/tasks/' + webhookTaskId + '/runs');
|
||||
T.assertHasKey(d, 'data', '/runs');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
T.assert(d.data.length >= 1, 'webhook task should have at least 1 run from trigger');
|
||||
T.assertShape(d.data[0], T.S.taskRun, 'run[0]');
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'DELETE /tasks/:id (webhook task)', async function () {
|
||||
await T.safeDelete('/tasks/' + webhookTaskId);
|
||||
webhookTaskId = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Webhook Secret Auto-Generation ──
|
||||
await T.test('crud', 'tasks', 'POST /tasks (webhook_url → auto webhook_secret)', async function () {
|
||||
var d = await T.apiPost('/tasks', {
|
||||
name: testTag + '-secret-task',
|
||||
task_type: 'prompt', schedule: '@daily',
|
||||
user_prompt: 'x', model_id: 'm',
|
||||
webhook_url: 'https://example.com/hook'
|
||||
});
|
||||
T.assert(d.webhook_secret && d.webhook_secret.length > 0,
|
||||
'webhook_secret should be auto-generated when webhook_url is set');
|
||||
if (d.id) await T.safeDelete('/tasks/' + d.id);
|
||||
});
|
||||
|
||||
// ── Admin Task Operations ──
|
||||
if (T.user.role === 'admin') {
|
||||
var adminTaskId = null;
|
||||
|
||||
await T.test('crud', 'tasks', 'POST /tasks (create for admin ops)', async function () {
|
||||
var d = await T.apiPost('/tasks', {
|
||||
name: testTag + '-admin-task',
|
||||
task_type: 'prompt', schedule: '@daily',
|
||||
user_prompt: 'admin test', model_id: 'm'
|
||||
});
|
||||
adminTaskId = d.id;
|
||||
T.registerCleanup(function () { if (adminTaskId) return T.safeDelete('/tasks/' + adminTaskId); });
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'GET /admin/tasks (list all)', async function () {
|
||||
var d = await T.apiGet('/admin/tasks');
|
||||
T.assertHasKey(d, 'data', '/admin/tasks');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
if (adminTaskId) {
|
||||
var found = d.data.some(function (t) { return t.id === adminTaskId; });
|
||||
T.assert(found, 'admin-created task should appear in admin list');
|
||||
}
|
||||
});
|
||||
|
||||
if (adminTaskId) {
|
||||
await T.test('crud', 'tasks', 'POST /admin/tasks/:id/run (admin run)', async function () {
|
||||
var d = await T.apiPost('/admin/tasks/' + adminTaskId + '/run', {});
|
||||
T.assertHasKey(d, 'scheduled', '/admin/run');
|
||||
T.assert(d.scheduled === true, 'should be scheduled');
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'POST /admin/tasks/:id/kill (admin kill)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'POST', '/admin/tasks/' + adminTaskId + '/kill');
|
||||
T.assert(d._status === 200 || d._status === 404,
|
||||
'expected 200 or 404, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'DELETE /admin/tasks/:id (admin delete)', async function () {
|
||||
await T.safeDelete('/admin/tasks/' + adminTaskId);
|
||||
adminTaskId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── System functions (v0.28.6) ──
|
||||
|
||||
await T.test('crud', 'tasks', 'GET /admin/system-functions', async function () {
|
||||
var d = await T.apiGet('/admin/system-functions');
|
||||
T.assertHasKey(d, 'data', 'system-functions');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
T.assert(d.data.length >= 4, 'should have at least 4 built-in functions, got ' + d.data.length);
|
||||
var names = d.data.map(function(f) { return f.name; });
|
||||
T.assert(names.indexOf('session_cleanup') >= 0, 'should include session_cleanup');
|
||||
T.assert(names.indexOf('staleness_check') >= 0, 'should include staleness_check');
|
||||
T.assert(names.indexOf('retention_sweep') >= 0, 'should include retention_sweep');
|
||||
T.assert(names.indexOf('health_prune') >= 0, 'should include health_prune');
|
||||
// Verify shape
|
||||
T.assert(typeof d.data[0].name === 'string', 'name should be string');
|
||||
T.assert(typeof d.data[0].description === 'string', 'description should be string');
|
||||
});
|
||||
|
||||
var sysTaskId = null;
|
||||
await T.test('crud', 'tasks', 'POST /tasks (create system task)', async function () {
|
||||
var d = await T.apiPost('/tasks', {
|
||||
name: testTag + '-system-task',
|
||||
task_type: 'system',
|
||||
system_function: 'health_prune',
|
||||
schedule: '0 3 * * *',
|
||||
scope: 'global',
|
||||
});
|
||||
T.assert(d.id, 'should return id');
|
||||
T.assert(d.task_type === 'system', 'task_type should be system');
|
||||
T.assert(d.system_function === 'health_prune', 'system_function should be health_prune');
|
||||
sysTaskId = d.id;
|
||||
T.registerCleanup(function () { if (sysTaskId) return T.safeDelete('/tasks/' + sysTaskId); });
|
||||
});
|
||||
|
||||
if (sysTaskId) {
|
||||
await T.test('crud', 'tasks', 'DELETE /tasks/:id (cleanup system task)', async function () {
|
||||
await T.safeDelete('/tasks/' + sysTaskId);
|
||||
sysTaskId = null;
|
||||
});
|
||||
}
|
||||
|
||||
await T.test('crud', 'tasks', 'POST /tasks (system task — invalid function → 400)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'POST', '/tasks', {
|
||||
name: 'bad-func',
|
||||
task_type: 'system',
|
||||
system_function: 'nonexistent_func',
|
||||
schedule: '0 3 * * *',
|
||||
});
|
||||
T.assertStatus(d, 400, 'invalid system function');
|
||||
});
|
||||
|
||||
await T.test('crud', 'tasks', 'POST /tasks (system task — missing function → 400)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'POST', '/tasks', {
|
||||
name: 'no-func',
|
||||
task_type: 'system',
|
||||
schedule: '0 3 * * *',
|
||||
});
|
||||
T.assertStatus(d, 400, 'missing system_function');
|
||||
});
|
||||
|
||||
};
|
||||
})();
|
||||
@@ -85,7 +85,8 @@
|
||||
user_prompt: 'Team task test',
|
||||
model_id: 'test-model'
|
||||
});
|
||||
T.assertShape(d, T.S.taskFull, 'team task');
|
||||
T.assert(typeof d.id === 'string', 'team task should have id');
|
||||
T.assert(typeof d.name === 'string', 'team task should have name');
|
||||
T.assert(d.scope === 'team', 'team task scope should be team');
|
||||
T.assert(d.team_id === teamId, 'team_id should match');
|
||||
teamTaskId = d.id;
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* ICD Test Runner — CRUD: Workflow Product (v0.35.0)
|
||||
* Tests for conditional routing, progressive forms, conditional fields,
|
||||
* review comments, monitoring dashboard, and SLA computation.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
if (!T.crud) T.crud = {};
|
||||
|
||||
T.crud.workflowProduct = async function (testTag) {
|
||||
|
||||
if (T.user.role !== 'admin') return;
|
||||
|
||||
var wfId = null;
|
||||
var wfSlug = testTag.toLowerCase().replace(/[^a-z0-9-]/g, '-') + '-wp';
|
||||
var channelId = null;
|
||||
|
||||
// ── Setup: Create workflow with conditions ──
|
||||
|
||||
await T.test('crud', 'workflow-product', 'Create workflow for routing tests', async function () {
|
||||
var d = await T.apiPost('/workflows', {
|
||||
name: testTag + '-routing-wf',
|
||||
slug: wfSlug,
|
||||
description: 'Workflow product routing test',
|
||||
entry_mode: 'team_only',
|
||||
});
|
||||
T.assert(d.id, 'workflow created');
|
||||
wfId = d.id;
|
||||
T.registerCleanup(function () { if (wfId) return T.safeDelete('/workflows/' + wfId); });
|
||||
});
|
||||
|
||||
if (!wfId) return;
|
||||
|
||||
// Create 3 stages: Intake → Review (condition: category=billing) → Fallback
|
||||
await T.test('crud', 'workflow-product', 'Create stage 0: Intake', async function () {
|
||||
var d = await T.apiPost('/workflows/' + wfId + '/stages', {
|
||||
name: 'Intake',
|
||||
ordinal: 0,
|
||||
stage_mode: 'form_only',
|
||||
form_template: { fields: [
|
||||
{ key: 'category', type: 'select', label: 'Category', required: true,
|
||||
options: [{ value: 'billing', label: 'Billing' }, { value: 'general', label: 'General' }] },
|
||||
{ key: 'notes', type: 'textarea', label: 'Notes', condition: { when: 'category', op: 'eq', value: 'general' } }
|
||||
] },
|
||||
history_mode: 'full',
|
||||
auto_transition: true,
|
||||
transition_rules: {
|
||||
conditions: [
|
||||
{ field: 'category', op: 'eq', value: 'billing', target_stage: 'Billing Review' }
|
||||
]
|
||||
},
|
||||
sla_seconds: 3600,
|
||||
});
|
||||
T.assert(d.id || d.name, 'stage created');
|
||||
});
|
||||
|
||||
await T.test('crud', 'workflow-product', 'Create stage 1: Billing Review', async function () {
|
||||
var d = await T.apiPost('/workflows/' + wfId + '/stages', {
|
||||
name: 'Billing Review',
|
||||
ordinal: 1,
|
||||
stage_mode: 'review',
|
||||
history_mode: 'full',
|
||||
sla_seconds: 7200,
|
||||
});
|
||||
T.assert(d.id || d.name, 'stage created');
|
||||
});
|
||||
|
||||
await T.test('crud', 'workflow-product', 'Create stage 2: General Fallback', async function () {
|
||||
var d = await T.apiPost('/workflows/' + wfId + '/stages', {
|
||||
name: 'General Fallback',
|
||||
ordinal: 2,
|
||||
stage_mode: 'chat_only',
|
||||
history_mode: 'full',
|
||||
});
|
||||
T.assert(d.id || d.name, 'stage created');
|
||||
});
|
||||
|
||||
// Activate and publish
|
||||
await T.test('crud', 'workflow-product', 'Activate + publish', async function () {
|
||||
await T.apiPatch('/workflows/' + wfId, { is_active: true });
|
||||
var d = await T.apiPost('/workflows/' + wfId + '/publish', {});
|
||||
T.assert(d.version_number >= 1, 'published');
|
||||
});
|
||||
|
||||
// ── Conditional routing test ──
|
||||
|
||||
await T.test('crud', 'workflow-product', 'Start instance + advance with condition match → routes to Billing Review', async function () {
|
||||
var start = await T.apiPost('/workflows/' + wfId + '/start', {});
|
||||
channelId = start.channel_id;
|
||||
T.assert(channelId, 'instance started');
|
||||
T.assert(start.current_stage === 0, 'starts at stage 0');
|
||||
|
||||
// Advance with category=billing → should route to stage 1 (Billing Review)
|
||||
var adv = await T.apiPost('/channels/' + channelId + '/workflow/advance', {
|
||||
data: { category: 'billing' }
|
||||
});
|
||||
T.assert(adv.current_stage === 1, 'routed to stage 1 (Billing Review), got: ' + adv.current_stage);
|
||||
T.assert(adv.stage && adv.stage.name === 'Billing Review', 'stage name is Billing Review');
|
||||
});
|
||||
|
||||
// ── Conditional field validation ──
|
||||
|
||||
await T.test('crud', 'workflow-product', 'Conditional field: hidden field skipped in validation', async function () {
|
||||
// The "notes" field has condition {when: "category", eq: "general"}
|
||||
// When category=billing, "notes" should be skipped even though it exists
|
||||
// This test verifies the server-side validation logic
|
||||
var start = await T.apiPost('/workflows/' + wfId + '/start', {});
|
||||
var ch = start.channel_id;
|
||||
T.assert(ch, 'second instance started');
|
||||
|
||||
// Submit form with category=billing and no notes → should succeed
|
||||
// (notes field condition not met, so it's skipped)
|
||||
var adv = await T.apiPost('/channels/' + ch + '/workflow/advance', {
|
||||
data: { category: 'billing' }
|
||||
});
|
||||
T.assert(adv.status === 'active' || adv.status === 'completed', 'advance succeeded without notes field');
|
||||
});
|
||||
|
||||
// ── Review comments ──
|
||||
|
||||
await T.test('crud', 'workflow-product', 'POST /workflow-assignments/:id/comment (add review comment)', async function () {
|
||||
// Create a new instance and advance to the review stage
|
||||
var start = await T.apiPost('/workflows/' + wfId + '/start', {});
|
||||
var ch = start.channel_id;
|
||||
|
||||
// Advance to Billing Review (stage 1)
|
||||
await T.apiPost('/channels/' + ch + '/workflow/advance', {
|
||||
data: { category: 'billing' }
|
||||
});
|
||||
|
||||
// Check status to verify we're at review stage
|
||||
var status = await T.apiGet('/channels/' + ch + '/workflow/status');
|
||||
T.assert(status.current_stage === 1, 'at review stage');
|
||||
});
|
||||
|
||||
// ── Monitoring dashboard ──
|
||||
|
||||
await T.test('crud', 'workflow-product', 'GET /admin/workflows/monitor/instances', async function () {
|
||||
var d = await T.apiGet('/admin/workflows/monitor/instances');
|
||||
T.assert(Array.isArray(d.data), 'returns array');
|
||||
// Should have at least the instances we created above
|
||||
});
|
||||
|
||||
await T.test('crud', 'workflow-product', 'GET /admin/workflows/monitor/funnel/:id', async function () {
|
||||
var d = await T.apiGet('/admin/workflows/monitor/funnel/' + wfId);
|
||||
T.assert(Array.isArray(d.data), 'returns array');
|
||||
T.assert(d.data.length === 3, 'has 3 stages in funnel, got: ' + d.data.length);
|
||||
});
|
||||
|
||||
await T.test('crud', 'workflow-product', 'GET /admin/workflows/monitor/stale', async function () {
|
||||
var d = await T.apiGet('/admin/workflows/monitor/stale?threshold_hours=0');
|
||||
T.assert(Array.isArray(d.data), 'returns array');
|
||||
// With threshold=0, all active instances should be "stale"
|
||||
});
|
||||
|
||||
// ── SLA fields ──
|
||||
|
||||
await T.test('crud', 'workflow-product', 'Monitor instances include SLA fields', async function () {
|
||||
var d = await T.apiGet('/admin/workflows/monitor/instances');
|
||||
var myInstances = d.data.filter(function (i) { return i.workflow_id === wfId; });
|
||||
if (myInstances.length > 0) {
|
||||
var inst = myInstances[0];
|
||||
T.assert('sla_seconds' in inst, 'has sla_seconds field');
|
||||
T.assert('sla_breached' in inst, 'has sla_breached field');
|
||||
T.assert('stage_age_seconds' in inst, 'has stage_age_seconds field');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Stage entered_at tracking ──
|
||||
|
||||
await T.test('crud', 'workflow-product', 'Workflow status includes stage_entered_at', async function () {
|
||||
if (!channelId) return;
|
||||
var status = await T.apiGet('/channels/' + channelId + '/workflow/status');
|
||||
T.assert('stage_entered_at' in status, 'stage_entered_at present in status');
|
||||
});
|
||||
|
||||
};
|
||||
})();
|
||||
@@ -1,198 +0,0 @@
|
||||
/**
|
||||
* ICD Test Runner — CRUD: Workspaces
|
||||
* Workspace lifecycle — create, read, update, file ops, stats, isolation, delete.
|
||||
* Git credentials list envelope.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
if (!T.crud) T.crud = {};
|
||||
|
||||
T.crud.workspaces = async function (testTag) {
|
||||
|
||||
// ── GET /workspaces — list envelope ──
|
||||
await T.test('crud', 'workspaces', 'GET /workspaces (list envelope)', async function () {
|
||||
var d = await T.apiGet('/workspaces');
|
||||
T.assertHasKey(d, 'data', 'GET /workspaces');
|
||||
T.assert(Array.isArray(d.data), 'data must be array');
|
||||
});
|
||||
|
||||
// ── GET /git-credentials — list envelope ──
|
||||
await T.test('crud', 'workspaces', 'GET /git-credentials (list envelope)', async function () {
|
||||
var d = await T.apiGet('/git-credentials');
|
||||
T.assertHasKey(d, 'data', 'GET /git-credentials');
|
||||
T.assert(Array.isArray(d.data), 'data must be array');
|
||||
});
|
||||
|
||||
// ── POST /git-credentials/generate — server-side keygen (v0.28.6) ──
|
||||
var generatedKeyId = null;
|
||||
await T.test('crud', 'workspaces', 'POST /git-credentials/generate', async function () {
|
||||
var d = await T.apiPost('/git-credentials/generate', { name: 'ICD Test Key' });
|
||||
T.assert(d.id, 'should return id');
|
||||
T.assert(d.auth_type === 'ssh_key', 'auth_type should be ssh_key');
|
||||
T.assert(d.public_key && d.public_key.length > 0, 'should return public_key');
|
||||
T.assert(d.fingerprint && d.fingerprint.startsWith('SHA256:'), 'should return SHA256 fingerprint');
|
||||
T.assert(!d.encrypted_data, 'must NOT expose encrypted_data');
|
||||
T.assert(!d.nonce, 'must NOT expose nonce');
|
||||
generatedKeyId = d.id;
|
||||
T.registerCleanup(function () { if (generatedKeyId) return T.safeDelete('/git-credentials/' + generatedKeyId); });
|
||||
});
|
||||
|
||||
if (generatedKeyId) {
|
||||
await T.test('crud', 'workspaces', 'GET /git-credentials/:id/public-key', async function () {
|
||||
var d = await T.apiGet('/git-credentials/' + generatedKeyId + '/public-key');
|
||||
T.assertHasKey(d, 'public_key', 'public-key response');
|
||||
T.assertHasKey(d, 'fingerprint', 'public-key response');
|
||||
T.assert(d.public_key.length > 0, 'public_key should be non-empty');
|
||||
});
|
||||
|
||||
await T.test('crud', 'workspaces', 'GET /git-credentials (list after generate)', async function () {
|
||||
var d = await T.apiGet('/git-credentials');
|
||||
var found = (d.data || []).find(function (c) { return c.id === generatedKeyId; });
|
||||
T.assert(found, 'generated key should appear in list');
|
||||
T.assert(found.public_key, 'list item should have public_key');
|
||||
T.assert(found.fingerprint, 'list item should have fingerprint');
|
||||
});
|
||||
|
||||
await T.test('crud', 'workspaces', 'DELETE /git-credentials/:id', async function () {
|
||||
var d = await T.apiDelete('/git-credentials/' + generatedKeyId);
|
||||
T.assert(d.deleted === true, 'should return deleted: true');
|
||||
generatedKeyId = null;
|
||||
});
|
||||
}
|
||||
|
||||
await T.test('crud', 'workspaces', 'POST /git-credentials/generate (missing name → 400)', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'POST', '/git-credentials/generate', {});
|
||||
T.assertStatus(d, 400, 'missing name');
|
||||
});
|
||||
|
||||
// ── Workspace CRUD ──
|
||||
var wsId = null;
|
||||
await T.test('crud', 'workspaces', 'POST /workspaces (create)', async function () {
|
||||
var d = await T.apiPost('/workspaces', {
|
||||
name: testTag + '-ws',
|
||||
owner_type: 'user',
|
||||
owner_id: T.user.id
|
||||
});
|
||||
T.assertShape(d, T.S.workspace, 'workspace');
|
||||
T.assert(d.owner_id === T.user.id, 'owner_id mismatch');
|
||||
T.assert(d.status === 'active', 'status should be active');
|
||||
wsId = d.id;
|
||||
T.registerCleanup(function () { if (wsId) return T.safeDelete('/workspaces/' + wsId); });
|
||||
});
|
||||
|
||||
if (wsId) {
|
||||
await T.test('crud', 'workspaces', 'GET /workspaces/:id (read)', async function () {
|
||||
var d = await T.apiGet('/workspaces/' + wsId);
|
||||
T.assertShape(d, T.S.workspace, 'workspace');
|
||||
T.assert(d.id === wsId, 'id mismatch');
|
||||
T.assert(d.name === testTag + '-ws', 'name mismatch');
|
||||
});
|
||||
|
||||
// ── PATCH /workspaces/:id ──
|
||||
await T.test('crud', 'workspaces', 'PATCH /workspaces/:id (update name)', async function () {
|
||||
var d = await T.apiPatch('/workspaces/' + wsId, { name: testTag + '-ws-updated' });
|
||||
T.assertShape(d, T.S.workspace, 'workspace-patched');
|
||||
T.assert(d.name === testTag + '-ws-updated', 'name not updated');
|
||||
});
|
||||
|
||||
// ── root_path must never be exposed ──
|
||||
await T.test('crud', 'workspaces', 'GET /workspaces/:id (no root_path)', async function () {
|
||||
var d = await T.apiGet('/workspaces/' + wsId);
|
||||
T.assert(d.root_path === undefined, 'root_path must not be exposed in API');
|
||||
});
|
||||
|
||||
// ── File operations ──
|
||||
await T.test('crud', 'workspaces', 'GET /workspaces/:id/files (list root)', async function () {
|
||||
var d = await T.apiGet('/workspaces/' + wsId + '/files?path=/');
|
||||
T.assertHasKey(d, 'data', 'ListFiles envelope');
|
||||
T.assert(Array.isArray(d.data), 'files should be array');
|
||||
});
|
||||
|
||||
// mkdir
|
||||
await T.test('crud', 'workspaces', 'POST /workspaces/:id/files/mkdir', async function () {
|
||||
var d = await T.apiPost('/workspaces/' + wsId + '/files/mkdir?path=/testdir', {});
|
||||
T.assert(d.ok === true, 'mkdir should return ok:true');
|
||||
T.assert(d.path === '/testdir', 'path mismatch');
|
||||
});
|
||||
|
||||
// Write a file then read it back
|
||||
await T.test('crud', 'workspaces', 'PUT /workspaces/:id/files/write', async function () {
|
||||
var d = await T.apiPut('/workspaces/' + wsId + '/files/write?path=' + encodeURIComponent('/test.txt'), {
|
||||
content: 'Hello from ICD test runner'
|
||||
});
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
});
|
||||
|
||||
await T.test('crud', 'workspaces', 'GET /workspaces/:id/files/read', async function () {
|
||||
var d = await T.apiGet('/workspaces/' + wsId + '/files/read?path=/test.txt');
|
||||
T.assertHasKey(d, 'content', '/ws-file-read');
|
||||
T.assert(d.content === 'Hello from ICD test runner', 'content mismatch');
|
||||
});
|
||||
|
||||
await T.test('crud', 'workspaces', 'GET /workspaces/:id/stats', async function () {
|
||||
var d = await T.apiGet('/workspaces/' + wsId + '/stats');
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
T.assertHasKey(d, 'file_count', 'stats.file_count');
|
||||
T.assertHasKey(d, 'total_bytes', 'stats.total_bytes');
|
||||
});
|
||||
|
||||
await T.test('crud', 'workspaces', 'GET /workspaces/:id/index-status', async function () {
|
||||
var d = await T.apiGet('/workspaces/' + wsId + '/index-status');
|
||||
T.assertHasKey(d, 'workspace_id', 'index-status.workspace_id');
|
||||
T.assertHasKey(d, 'status_counts', 'index-status.status_counts');
|
||||
T.assertHasKey(d, 'total_chunks', 'index-status.total_chunks');
|
||||
});
|
||||
|
||||
// ── File cleanup ──
|
||||
await T.test('crud', 'workspaces', 'DELETE /workspaces/:id/files (file)', async function () {
|
||||
try {
|
||||
await T.safeDelete('/workspaces/' + wsId + '/files/delete?path=' + encodeURIComponent('/test.txt'));
|
||||
} catch (e) {
|
||||
if (e.message && e.message.indexOf('404') === -1) throw e;
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('crud', 'workspaces', 'DELETE /workspaces/:id', async function () {
|
||||
await T.safeDelete('/workspaces/' + wsId);
|
||||
wsId = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Workspace isolation: other user cannot access ──
|
||||
// Create workspace as running admin user, try to access from fixture regular user
|
||||
if (T.fixtures.ready) {
|
||||
var fixtureUser = T.getFixtureUser('-user');
|
||||
if (fixtureUser && fixtureUser.token) {
|
||||
var isolWsId = null;
|
||||
|
||||
await T.test('crud', 'workspaces', 'isolation: create admin workspace', async function () {
|
||||
var d = await T.apiPost('/workspaces', {
|
||||
name: testTag + '-iso-ws',
|
||||
owner_type: 'user',
|
||||
owner_id: T.user.id
|
||||
});
|
||||
T.assertShape(d, T.S.workspace, 'workspace-iso');
|
||||
isolWsId = d.id;
|
||||
T.registerCleanup(function () { if (isolWsId) return T.safeDelete('/workspaces/' + isolWsId); });
|
||||
});
|
||||
|
||||
if (isolWsId) {
|
||||
await T.test('crud', 'workspaces', 'isolation: user cannot GET other workspace', async function () {
|
||||
var d = await T.authFetch(fixtureUser.token, 'GET', '/workspaces/' + isolWsId);
|
||||
T.assert(d._status === 403, 'expected 403, got ' + d._status);
|
||||
});
|
||||
|
||||
// cleanup
|
||||
await T.test('crud', 'workspaces', 'isolation: cleanup admin workspace', async function () {
|
||||
await T.safeDelete('/workspaces/' + isolWsId);
|
||||
isolWsId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user