404 lines
19 KiB
JavaScript
404 lines
19 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; }
|
|
});
|
|
}
|
|
})();
|
|
|
|
};
|
|
})();
|