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