stage_type no longer validated — starlark_hook presence determines automation. stage_mode collapsed from 4→3 values (form/delegated/ automated); "review" mapped to "form" on input for backward compat. Migration 018 converts existing rows. Review surface removed (~110 lines). 4 package manifests updated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
739 lines
34 KiB
JavaScript
739 lines
34 KiB
JavaScript
/**
|
|
* 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; }
|
|
});
|
|
}
|
|
})();
|
|
}
|
|
|
|
};
|
|
})();
|