/** * ICD Test Runner — CRUD: Workflows * Admin workflow lifecycle (create → stages → activate → publish → * start instance → advance → reject → delete), visitor E2E, * assignment claim/complete lifecycle. */ (function () { 'use strict'; var T = window.ICD; if (!T) return; if (!T.crud) T.crud = {}; T.crud.workflows = async function (testTag) { // ── Workflows CRUD (admin — requires workflow.create permission) ── if (T.user.role === 'admin') { var wfId = null; var wfSlug = testTag.toLowerCase().replace(/[^a-z0-9-]/g, '-'); await T.test('crud', 'workflows', 'POST /workflows (create)', async function () { var d = await T.apiPost('/workflows', { name: testTag + '-workflow', slug: wfSlug, description: 'ICD integration test workflow', entry_mode: 'team_only', branding: { accent_color: '#2563eb', tagline: 'ICD Test' }, retention: { mode: 'archive', delete_after_days: 1 } }); T.assertShape(d, T.S.workflow, 'workflow'); T.assert(d.slug === wfSlug, 'slug mismatch: expected ' + wfSlug + ', got ' + d.slug); wfId = d.id; T.registerCleanup(function () { if (wfId) return T.safeDelete('/workflows/' + wfId); }); }); if (wfId) { await T.test('crud', 'workflows', 'GET /workflows/:id (read)', async function () { var d = await T.apiGet('/workflows/' + wfId); T.assertShape(d, T.S.workflow, 'workflow'); T.assert(d.id === wfId, 'id mismatch'); T.assert(d.name.indexOf(testTag) !== -1, 'name mismatch'); }); await T.test('crud', 'workflows', 'PATCH /workflows/:id (update)', async function () { var d = await T.apiPatch('/workflows/' + wfId, { description: 'Updated by ICD test runner', webhook_url: 'https://example.com/hook' }); T.assert(typeof d === 'object', 'expected object'); }); // ── Stages ── var stageIds = []; await T.test('crud', 'workflows', 'POST /workflows/:id/stages (create #1)', async function () { var d = await T.apiPost('/workflows/' + wfId + '/stages', { name: 'Intake', ordinal: 0, persona_id: null, form_template: { fields: ['name', 'email'] }, history_mode: 'full', auto_transition: false, transition_rules: {} }); T.assertShape(d, T.S.workflowStage, 'stage'); T.assert(d.name === 'Intake', 'name mismatch'); stageIds.push(d.id); }); await T.test('crud', 'workflows', 'POST /workflows/:id/stages (create #2)', async function () { var d = await T.apiPost('/workflows/' + wfId + '/stages', { name: 'Review', ordinal: 1, persona_id: null, form_template: {}, history_mode: 'summary', auto_transition: false, transition_rules: {} }); T.assertShape(d, T.S.workflowStage, 'stage'); stageIds.push(d.id); }); await T.test('crud', 'workflows', 'GET /workflows/:id/stages (list)', async function () { var d = await T.apiGet('/workflows/' + wfId + '/stages'); T.assertHasKey(d, 'data', '/stages'); T.assert(Array.isArray(d.data), 'data should be array'); T.assert(d.data.length >= 2, 'expected at least 2 stages, got ' + d.data.length); T.assertShape(d.data[0], T.S.workflowStage, 'stage[0]'); }); await T.test('crud', 'workflows', 'PUT /workflows/:id/stages/:sid (update)', async function () { var d = await T.apiPut('/workflows/' + wfId + '/stages/' + stageIds[0], { name: 'Intake (updated)', ordinal: 0, persona_id: null, form_template: { fields: ['name', 'email', 'phone'] }, history_mode: 'full', auto_transition: false, transition_rules: {} }); T.assert(typeof d === 'object', 'expected object'); }); if (stageIds.length >= 2) { await T.test('crud', 'workflows', 'PATCH /workflows/:id/stages/reorder', async function () { var reversed = stageIds.slice().reverse(); var d = await T.apiPatch('/workflows/' + wfId + '/stages/reorder', { ordered_ids: reversed }); T.assert(typeof d === 'object', 'expected object'); }); // Restore original order for subsequent tests await T.apiPatch('/workflows/' + wfId + '/stages/reorder', { ordered_ids: stageIds }); } // ── Activate workflow (required before starting instances) ── await T.test('crud', 'workflows', 'PATCH /workflows/:id (activate)', async function () { var d = await T.apiPatch('/workflows/' + wfId, { is_active: true }); T.assert(typeof d === 'object', 'expected object'); }); // ── Publish version ── var versionNum = null; await T.test('crud', 'workflows', 'POST /workflows/:id/publish', async function () { var d = await T.apiPost('/workflows/' + wfId + '/publish', {}); T.assertShape(d, T.S.workflowVersion, 'version'); T.assertHasKey(d, 'snapshot', 'version'); versionNum = d.version_number; }); if (versionNum !== null) { await T.test('crud', 'workflows', 'GET /workflows/:id/versions/:v (read)', async function () { var d = await T.apiGet('/workflows/' + wfId + '/versions/' + versionNum); T.assertShape(d, T.S.workflowVersion, 'version'); T.assert(d.version_number === versionNum, 'version mismatch'); T.assertHasKey(d, 'snapshot', 'version'); }); await T.test('crud', 'workflows', 'POST /workflows/:id/publish (409 duplicate)', async function () { try { await T.apiPost('/workflows/' + wfId + '/publish', {}); throw new Error('expected 409 conflict, got success'); } catch (e) { // 409 is the expected result if (e.message.indexOf('409') === -1 && e.message.indexOf('conflict') === -1 && e.message.indexOf('already') === -1 && e.message.indexOf('expected 409') !== -1) throw e; // Otherwise: got the expected rejection } }); } // ── Start instance → status → advance → reject ── var instanceChannelId = null; await T.test('crud', 'workflows', 'POST /workflows/:id/start (instance)', async function () { var d = await T.apiPost('/workflows/' + wfId + '/start', {}); // Response may be: direct channel { id }, or { channel_id }, or { channel: { id } }, or { data: { id } } var cid = d.id || d.channel_id || (d.channel && d.channel.id) || (d.data && d.data.id); T.assert(cid, 'no channel ID in start response, got keys: ' + Object.keys(d).join(', ')); instanceChannelId = cid; T.registerCleanup(function () { if (instanceChannelId) return T.safeDelete('/channels/' + instanceChannelId); }); }); if (instanceChannelId) { await T.test('crud', 'workflows', 'GET /channels/:id/workflow/status', async function () { var d = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status'); T.assertShape(d, T.S.workflowStatus, 'status'); T.assert(d.current_stage === 0, 'expected stage 0, got ' + d.current_stage); T.assert(d.status === 'active', 'expected active, got ' + d.status); }); await T.test('crud', 'workflows', 'POST /channels/:id/workflow/advance', async function () { var d = await T.apiPost('/channels/' + instanceChannelId + '/workflow/advance', { data: { name: 'ICD Test', email: 'test@icd.local' } }); T.assert(typeof d === 'object', 'expected object'); }); await T.test('crud', 'workflows', 'GET workflow/status (after advance)', async function () { var d = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status'); T.assertShape(d, T.S.workflowStatus, 'status'); // Should be at stage 1 (or completed if only 2 stages and auto-completed) T.assert(d.current_stage >= 1 || d.status === 'completed', 'expected stage >= 1 or completed, got stage=' + d.current_stage + ' status=' + d.status); }); // Reject back to stage 0 (only if still active and not at stage 0) await T.test('crud', 'workflows', 'POST /channels/:id/workflow/reject', async function () { var st = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status'); if (st.status === 'completed' || st.current_stage === 0) { // Can't reject from stage 0 or completed — skip gracefully return; } var d = await T.apiPost('/channels/' + instanceChannelId + '/workflow/reject', { reason: 'ICD test: rejecting back to previous stage' }); T.assert(typeof d === 'object', 'expected object'); }); // Verify reject went back await T.test('crud', 'workflows', 'GET workflow/status (after reject)', async function () { var d = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status'); T.assertShape(d, T.S.workflowStatus, 'status'); // Should be back at 0 or still active T.assert(d.status === 'active' || d.status === 'completed', 'expected active or completed, got ' + d.status); }); // Clean up instance channel before workflow delete (cascade may do it, but be explicit) await T.test('crud', 'workflows', 'DELETE instance channel', async function () { await T.safeDelete('/channels/' + instanceChannelId); instanceChannelId = null; }); } // ── Stage delete ── if (stageIds.length >= 2) { await T.test('crud', 'workflows', 'DELETE /workflows/:id/stages/:sid', async function () { await T.safeDelete('/workflows/' + wfId + '/stages/' + stageIds[stageIds.length - 1]); stageIds.pop(); }); } // ── Workflow delete ── await T.test('crud', 'workflows', 'DELETE /workflows/:id', async function () { await T.safeDelete('/workflows/' + wfId); wfId = null; }); } } // ── Visitor Workflow E2E ────────────────────────────────── // Tests the anonymous session flow: create public_link workflow → visitor // starts via entry API (no JWT) → sends messages via session cookie. await (async function () { var vwfId = null; var vwfSlug = testTag + '-visitor-wf'; var visitorChannelId = null; await T.test('crud', 'workflows', 'POST visitor workflow (public_link)', async function () { var d = await T.apiPost('/workflows', { name: testTag + ' Visitor WF', slug: vwfSlug, entry_mode: 'public_link' }); T.assertShape(d, T.S.workflow, 'visitor workflow'); vwfId = d.id; T.registerCleanup(function () { if (vwfId) return T.safeDelete('/workflows/' + vwfId); }); }); if (vwfId) { await T.test('crud', 'workflows', 'POST visitor workflow stage', async function () { await T.apiPost('/workflows/' + vwfId + '/stages', { name: 'Intake', ordinal: 0, history_mode: 'full' }); }); await T.test('crud', 'workflows', 'PATCH+publish visitor workflow', async function () { await T.apiPatch('/workflows/' + vwfId, { is_active: true }); var pub = await T.apiPost('/workflows/' + vwfId + '/publish', {}); T.assert(pub._status === undefined || pub._status === 201 || pub.version_number, 'publish failed'); }); await T.test('crud', 'workflows', 'POST /workflow-entry (visitor start, no auth)', async function () { var d = await T.publicPost('/workflow-entry/global/' + vwfSlug, {}); T.assert(d._status === 201, 'expected 201, got ' + d._status); T.assertHasKey(d, 'channel_id', 'visitor start'); T.assertHasKey(d, 'session_id', 'visitor start'); T.assertHasKey(d, 'redirect_to', 'visitor start'); visitorChannelId = d.channel_id; T.registerCleanup(function () { if (visitorChannelId) return T.safeDelete('/channels/' + visitorChannelId); }); }); if (visitorChannelId) { await T.test('crud', 'workflows', 'POST /w/:id/messages (visitor session)', async function () { var d = await T.sessionPost('/w/' + visitorChannelId + '/messages', { content: 'Hello from visitor test', role: 'user' }); T.assert(d._status === 200 || d._status === 201, 'visitor message: got ' + d._status); }); await T.test('crud', 'workflows', 'GET /w/:id/messages (visitor session)', async function () { var d = await T.sessionGet('/w/' + visitorChannelId + '/messages'); T.assert(d._status === 200, 'visitor list messages: got ' + d._status); var msgs = d.data || d.messages || d; T.assert(Array.isArray(msgs), 'expected messages array'); T.assert(msgs.length > 0, 'no messages returned for visitor channel'); }); await T.test('crud', 'workflows', 'GET workflow/status (visitor instance)', async function () { var d = await T.apiGet('/channels/' + visitorChannelId + '/workflow/status'); T.assertShape(d, T.S.workflowStatus, 'visitor workflow status'); T.assert(d.status === 'active', 'visitor workflow should be active'); }); } await T.test('crud', 'workflows', 'DELETE visitor workflow', async function () { if (visitorChannelId) { await T.safeDelete('/channels/' + visitorChannelId); visitorChannelId = null; } if (vwfId) { await T.safeDelete('/workflows/' + vwfId); vwfId = null; } }); } })(); // ── Assignment Claim / Complete ──────────────────────────── // Tests the full assignment lifecycle: advance creates assignment → // claim → double-claim 409 → complete → double-complete 409. await (async function () { var awfId = null; var aTeamId = null; var aChannelId = null; var assignmentId = null; await T.test('crud', 'workflows', 'assignment: create team + workflow', async function () { var team = await T.apiPost('/admin/teams', { name: testTag + '-assign-team', description: 'Assignment test' }); aTeamId = team.id; T.registerCleanup(function () { if (aTeamId) return T.safeDelete('/admin/teams/' + aTeamId); }); try { await T.apiPost('/admin/teams/' + aTeamId + '/members', { user_id: T.user.id, role: 'admin' }); } catch (e) { /* auto-add */ } var wf = await T.apiPost('/workflows', { name: testTag + '-assign-wf', entry_mode: 'team_only' }); awfId = wf.id; T.registerCleanup(function () { if (awfId) return T.safeDelete('/workflows/' + awfId); }); await T.apiPost('/workflows/' + awfId + '/stages', { name: 'Collect', ordinal: 0, history_mode: 'full' }); await T.apiPost('/workflows/' + awfId + '/stages', { name: 'Review', ordinal: 1, history_mode: 'full', assignment_team_id: aTeamId }); await T.apiPatch('/workflows/' + awfId, { is_active: true }); await T.apiPost('/workflows/' + awfId + '/publish', {}); }); if (awfId) { await T.test('crud', 'workflows', 'assignment: start + advance to review stage', async function () { var inst = await T.apiPost('/workflows/' + awfId + '/start', {}); T.assertHasKey(inst, 'channel_id', 'instance start'); aChannelId = inst.channel_id; T.registerCleanup(function () { if (aChannelId) return T.safeDelete('/channels/' + aChannelId); }); var adv = await T.apiPost('/channels/' + aChannelId + '/workflow/advance', { data: { intake: 'done' } }); T.assert(adv.current_stage === 1, 'should be at stage 1 after advance'); }); await T.test('crud', 'workflows', 'assignment: GET /workflow-assignments/mine', async function () { var d = await T.apiGet('/workflow-assignments/mine'); var arr = d.data || []; T.assert(arr.length > 0, 'expected at least 1 assignment for my teams'); var ours = arr.find(function (a) { return a.team_id === aTeamId; }); T.assert(ours, 'no assignment found for test team'); assignmentId = ours.id; }); if (assignmentId) { await T.test('crud', 'workflows', 'assignment: POST claim', async function () { var d = await T.apiPost('/workflow-assignments/' + assignmentId + '/claim', {}); T.assert(d.claimed === true, 'claim should return claimed=true'); }); await T.test('crud', 'workflows', 'assignment: POST claim (duplicate → 409)', async function () { var d = await T.authFetch(await T.getAuthToken(), 'POST', '/workflow-assignments/' + assignmentId + '/claim', {}); T.assert(d._status === 409, 'double claim should return 409, got ' + d._status); }); await T.test('crud', 'workflows', 'assignment: POST complete', async function () { var d = await T.apiPost('/workflow-assignments/' + assignmentId + '/complete', {}); T.assert(d.completed === true, 'complete should return completed=true'); }); await T.test('crud', 'workflows', 'assignment: POST complete (duplicate → 409)', async function () { var d = await T.authFetch(await T.getAuthToken(), 'POST', '/workflow-assignments/' + assignmentId + '/complete', {}); T.assert(d._status === 409, 'double complete should return 409, got ' + d._status); }); } await T.test('crud', 'workflows', 'assignment: cleanup', async function () { if (aChannelId) { await T.safeDelete('/channels/' + aChannelId); aChannelId = null; } if (awfId) { await T.safeDelete('/workflows/' + awfId); awfId = null; } if (aTeamId) { await T.safeDelete('/admin/teams/' + aTeamId); aTeamId = null; } }); } })(); // ── Form Submission Lifecycle (v0.29.3) ────────────────── // Tests typed form_template with stage_mode, form validation, // and the /w/:id/form-submit endpoint. if (T.user.role === 'admin') { await (async function () { var fwfId = null; var fwfSlug = testTag + '-form-wf'; var fChannelId = null; await T.test('crud', 'workflows', 'form: create form workflow', async function () { var wf = await T.apiPost('/workflows', { name: testTag + ' Form WF', slug: fwfSlug, entry_mode: 'public_link' }); T.assertShape(wf, T.S.workflow, 'form workflow'); fwfId = wf.id; T.registerCleanup(function () { if (fwfId) return T.safeDelete('/workflows/' + fwfId); }); await T.apiPost('/workflows/' + fwfId + '/stages', { name: 'Contact Info', ordinal: 0, history_mode: 'full', stage_mode: 'form', form_template: { fields: [ { key: 'name', type: 'text', label: 'Full Name', required: true, validation: { min_length: 2 } }, { key: 'email', type: 'email', label: 'Email', required: true }, { key: 'dept', type: 'select', label: 'Department', options: [ { value: 'eng', label: 'Engineering' }, { value: 'sales', label: 'Sales' } ]} ] } }); await T.apiPost('/workflows/' + fwfId + '/stages', { name: 'Done', ordinal: 1, history_mode: 'full', stage_mode: 'form' }); await T.apiPatch('/workflows/' + fwfId, { is_active: true }); await T.apiPost('/workflows/' + fwfId + '/publish', {}); }); if (fwfId) { await T.test('crud', 'workflows', 'form: start instance', async function () { var inst = await T.apiPost('/workflows/' + fwfId + '/start', {}); fChannelId = inst.channel_id || inst.id; T.assert(fChannelId, 'no channel_id in form start'); T.registerCleanup(function () { if (fChannelId) return T.safeDelete('/channels/' + fChannelId); }); }); if (fChannelId) { await T.test('crud', 'workflows', 'form: GET /w/:id/form (template)', async function () { var d = await T.sessionGet('/w/' + fChannelId + '/form'); T.assert(d._status === 200, 'expected 200, got ' + d._status); T.assertHasKey(d, 'stage_mode', 'form response'); T.assert(d.stage_mode === 'form', 'expected form, got ' + d.stage_mode); T.assertHasKey(d, 'form_template', 'form response'); T.assert(d.form_template.fields && d.form_template.fields.length === 3, 'expected 3 fields, got ' + (d.form_template.fields ? d.form_template.fields.length : 0)); }); await T.test('crud', 'workflows', 'form: submit missing required → 400', async function () { var d = await T.sessionPost('/w/' + fChannelId + '/form-submit', { dept: 'eng' }); T.assert(d._status === 400, 'expected 400, got ' + d._status); T.assertHasKey(d, 'errors', 'validation response'); T.assert(Array.isArray(d.errors) && d.errors.length > 0, 'expected field errors'); }); await T.test('crud', 'workflows', 'form: submit invalid email → 400', async function () { var d = await T.sessionPost('/w/' + fChannelId + '/form-submit', { name: 'Test User', email: 'not-an-email' }); T.assert(d._status === 400, 'expected 400, got ' + d._status); T.assertHasKey(d, 'errors', 'validation response'); var emailErr = d.errors.find(function (e) { return e.key === 'email'; }); T.assert(emailErr, 'expected email validation error'); }); await T.test('crud', 'workflows', 'form: submit valid → 200', async function () { var d = await T.sessionPost('/w/' + fChannelId + '/form-submit', { name: 'ICD Test User', email: 'test@icd.local', dept: 'eng' }); T.assert(d._status === 200, 'expected 200, got ' + d._status); }); await T.test('crud', 'workflows', 'form: verify stage_data after submit', async function () { var d = await T.apiGet('/channels/' + fChannelId + '/workflow/status'); T.assertShape(d, T.S.workflowStatus, 'status'); // stage_data should contain submitted form values if (d.stage_data) { var sd = typeof d.stage_data === 'string' ? JSON.parse(d.stage_data) : d.stage_data; T.assert(sd.name === 'ICD Test User' || sd.form_name === 'ICD Test User', 'stage_data should contain form values'); } }); } await T.test('crud', 'workflows', 'form: cleanup', async function () { if (fChannelId) { await T.safeDelete('/channels/' + fChannelId); fChannelId = null; } if (fwfId) { await T.safeDelete('/workflows/' + fwfId); fwfId = null; } }); } })(); } // ── Cross-Visitor Isolation (v0.29.3) ───────────────────── // Two instances of the same workflow should have isolated stage_data // and one visitor cannot access the other's form endpoint. if (T.user.role === 'admin') { await (async function () { var xwfId = null; var xwfSlug = testTag + '-xvisitor'; var chA = null, chB = null; await T.test('crud', 'workflows', 'xvisitor: setup form workflow', async function () { var wf = await T.apiPost('/workflows', { name: testTag + ' XVisitor', slug: xwfSlug, entry_mode: 'public_link' }); xwfId = wf.id; T.registerCleanup(function () { if (xwfId) return T.safeDelete('/workflows/' + xwfId); }); await T.apiPost('/workflows/' + xwfId + '/stages', { name: 'Form', ordinal: 0, stage_mode: 'form', history_mode: 'full', form_template: { fields: [{ key: 'name', type: 'text', label: 'Name', required: true }] } }); await T.apiPost('/workflows/' + xwfId + '/stages', { name: 'Done', ordinal: 1, history_mode: 'full' }); await T.apiPatch('/workflows/' + xwfId, { is_active: true }); await T.apiPost('/workflows/' + xwfId + '/publish', {}); }); if (xwfId) { await T.test('crud', 'workflows', 'xvisitor: start two instances', async function () { var a = await T.apiPost('/workflows/' + xwfId + '/start', {}); chA = a.channel_id || a.id; T.assert(chA, 'no channel A'); T.registerCleanup(function () { if (chA) return T.safeDelete('/channels/' + chA); }); var b = await T.apiPost('/workflows/' + xwfId + '/start', {}); chB = b.channel_id || b.id; T.assert(chB, 'no channel B'); T.registerCleanup(function () { if (chB) return T.safeDelete('/channels/' + chB); }); T.assert(chA !== chB, 'channels should be different'); }); if (chA && chB) { await T.test('crud', 'workflows', 'xvisitor: submit different data to each', async function () { // Submit to A (admin token) var dA = await T.apiPost('/channels/' + chA + '/workflow/advance', { data: { name: 'Alice' } }); T.assert(typeof dA === 'object', 'advance A'); // Submit to B var dB = await T.apiPost('/channels/' + chB + '/workflow/advance', { data: { name: 'Bob' } }); T.assert(typeof dB === 'object', 'advance B'); }); await T.test('crud', 'workflows', 'xvisitor: verify stage_data isolated', async function () { var stA = await T.apiGet('/channels/' + chA + '/workflow/status'); var stB = await T.apiGet('/channels/' + chB + '/workflow/status'); // Parse stage_data var sdA = stA.stage_data; if (typeof sdA === 'string') sdA = JSON.parse(sdA); var sdB = stB.stage_data; if (typeof sdB === 'string') sdB = JSON.parse(sdB); // A should have Alice, B should have Bob var aName = sdA && (sdA.name || sdA.form_name); var bName = sdB && (sdB.name || sdB.form_name); T.assert(aName === 'Alice', 'channel A stage_data should have Alice, got ' + aName); T.assert(bName === 'Bob', 'channel B stage_data should have Bob, got ' + bName); }); } await T.test('crud', 'workflows', 'xvisitor: cleanup', async function () { if (chA) { await T.safeDelete('/channels/' + chA); chA = null; } if (chB) { await T.safeDelete('/channels/' + chB); chB = null; } if (xwfId) { await T.safeDelete('/workflows/' + xwfId); xwfId = null; } }); } })(); } // ── Workflow Package Export/Import (v0.30.2) ── if (T.user.role === 'admin') { await (async function () { var wpWfId = null; var wpStage1Id = null; var wpStage2Id = null; var wpPkgId = null; var wpSlug = testTag.toLowerCase().replace(/[^a-z0-9-]/g, '-') + '-wp'; await T.test('crud', 'workflows', 'wfpkg: create workflow for export', async function () { var d = await T.apiPost('/workflows', { name: testTag + '-wfpkg', slug: wpSlug, description: 'Workflow package export/import test', entry_mode: 'public_link' }); T.assertShape(d, T.S.workflow, 'workflow'); wpWfId = d.id; T.registerCleanup(function () { if (wpWfId) return T.safeDelete('/workflows/' + wpWfId); }); }); if (wpWfId) { await T.test('crud', 'workflows', 'wfpkg: add stages with form_template', async function () { var s1 = await T.apiPost('/workflows/' + wpWfId + '/stages', { name: 'Intake', ordinal: 0, stage_mode: 'form', history_mode: 'full', form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] } }); T.assertShape(s1, T.S.workflowStage, 'stage1'); wpStage1Id = s1.id; var s2 = await T.apiPost('/workflows/' + wpWfId + '/stages', { name: 'Review', ordinal: 1, stage_mode: 'form', history_mode: 'summary' }); T.assertShape(s2, T.S.workflowStage, 'stage2'); wpStage2Id = s2.id; }); // Test surface_pkg_id round-trip await T.test('crud', 'workflows', 'wfpkg: surface_pkg_id persists on update', async function () { // Stage update is PUT (full replace), so send all required fields. // Use the ICD test runner's own package ID (always installed when tests run). var realPkgId = 'icd-test-runner'; var stageBase = { name: 'Intake', ordinal: 0, stage_mode: 'form', history_mode: 'full', form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] } }; // Set surface_pkg_id var withPkg = Object.assign({}, stageBase, { surface_pkg_id: realPkgId }); var updated = await T.apiPut('/workflows/' + wpWfId + '/stages/' + wpStage1Id, withPkg); T.assert(updated.surface_pkg_id === realPkgId, 'surface_pkg_id should persist, got: ' + updated.surface_pkg_id); // Clear it var cleared = await T.apiPut('/workflows/' + wpWfId + '/stages/' + wpStage1Id, stageBase); T.assert(!cleared.surface_pkg_id, 'surface_pkg_id should be cleared, got: ' + cleared.surface_pkg_id); }); // Export workflow as .pkg var exportBlob = null; await T.test('crud', 'workflows', 'wfpkg: GET export .pkg', async function () { var token = await T.getAuthToken(); var resp = await fetch(T.base + '/api/v1/admin/workflows/' + wpWfId + '/export', { headers: { 'Authorization': 'Bearer ' + token } }); T.assert(resp.ok, 'export should return 200, got ' + resp.status); var ct = resp.headers.get('content-type') || ''; T.assert(ct.indexOf('zip') !== -1 || ct.indexOf('octet') !== -1, 'content-type should be zip/octet, got ' + ct); exportBlob = await resp.blob(); T.assert(exportBlob.size > 0, 'export blob should not be empty'); }); // Delete original workflow before import to test clean install await T.test('crud', 'workflows', 'wfpkg: delete original workflow', async function () { await T.apiDelete('/workflows/' + wpWfId); wpWfId = null; }); // Import the .pkg if (exportBlob) { await T.test('crud', 'workflows', 'wfpkg: POST install .pkg (re-import)', async function () { var token = await T.getAuthToken(); var formData = new FormData(); formData.append('file', exportBlob, 'test-workflow.pkg'); var resp = await fetch(T.base + '/api/v1/admin/packages/install', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData }); var result = await resp.json(); T.assert(resp.ok, 'install should return 200, got ' + resp.status + ': ' + (result.error || '')); T.assert(result.id, 'install should return package id'); wpPkgId = result.id; T.registerCleanup(function () { if (wpPkgId) return T.safeDelete('/admin/packages/' + wpPkgId); }); }); // Verify the workflow was recreated await T.test('crud', 'workflows', 'wfpkg: verify imported workflow has stages', async function () { var wfs = await T.apiGet('/workflows'); var list = wfs.data || wfs || []; var found = list.find(function (w) { return w.slug === wpSlug; }); T.assert(found, 'imported workflow with slug ' + wpSlug + ' should exist'); wpWfId = found.id; var stages = await T.apiGet('/workflows/' + wpWfId + '/stages'); var stList = stages.data || stages || []; T.assert(stList.length === 2, 'should have 2 stages, got ' + stList.length); T.assert(stList[0].name === 'Intake', 'stage 0 name should be Intake'); T.assert(stList[1].name === 'Review', 'stage 1 name should be Review'); T.assert(stList[0].stage_mode === 'form', 'stage 0 mode should be form'); T.assert(stList[1].stage_mode === 'form', 'stage 1 mode should be form'); }); } // Cleanup await T.test('crud', 'workflows', 'wfpkg: cleanup', async function () { if (wpPkgId) { await T.safeDelete('/admin/packages/' + wpPkgId); wpPkgId = null; } if (wpWfId) { await T.safeDelete('/workflows/' + wpWfId); wpWfId = null; } }); } })(); } }; })();