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