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