454 lines
15 KiB
JavaScript
454 lines
15 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { compileFunction } from 'node:vm';
|
|
import ts from 'typescript';
|
|
const require = createRequire(import.meta.url);
|
|
function load(file, mocks = {}, suffix = '') {
|
|
const code = ts.transpileModule(
|
|
fs.readFileSync(new URL(file, import.meta.url), 'utf8') + suffix,
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
jsx: ts.JsxEmit.ReactJSX,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
},
|
|
).outputText;
|
|
const module = { exports: {} };
|
|
compileFunction(code, ['require', 'module', 'exports'])(
|
|
(name) => mocks[name] ?? require(name),
|
|
module,
|
|
module.exports,
|
|
);
|
|
return module.exports;
|
|
}
|
|
function nodes(tree) {
|
|
if (Array.isArray(tree)) return tree.flatMap(nodes);
|
|
if (!tree || typeof tree !== 'object') return [];
|
|
return [tree, ...nodes(tree.props?.children)];
|
|
}
|
|
const text = (tree) =>
|
|
typeof tree === 'string'
|
|
? tree
|
|
: Array.isArray(tree)
|
|
? tree.map(text).join('')
|
|
: tree?.props
|
|
? text(tree.props.children)
|
|
: typeof tree === 'number'
|
|
? String(tree)
|
|
: '';
|
|
const Empty = () => null;
|
|
function harness(file, initial, extra = {}, suffix = '') {
|
|
const cells = [],
|
|
forms = [],
|
|
sent = [];
|
|
let cursor = 0,
|
|
formCursor = 0,
|
|
props = initial,
|
|
tree;
|
|
const mocks = {
|
|
react: {
|
|
useState(initial) {
|
|
const i = cursor++;
|
|
if (!(i in cells)) cells[i] = initial;
|
|
return [
|
|
cells[i],
|
|
(value) => {
|
|
cells[i] =
|
|
typeof value === 'function'
|
|
? value(cells[i])
|
|
: value;
|
|
},
|
|
];
|
|
},
|
|
useRef(initial) {
|
|
const i = cursor++;
|
|
cells[i] ??= { current: initial };
|
|
return cells[i];
|
|
},
|
|
},
|
|
'@inertiajs/react': {
|
|
Link: 'a',
|
|
router: { reload() {}, post() {} },
|
|
usePage: () => ({
|
|
url: '/synthetic',
|
|
props: { auth: { user: { id: 1 } } },
|
|
}),
|
|
useForm(initial) {
|
|
const i = formCursor++;
|
|
forms[i] ??= {
|
|
data: structuredClone(initial),
|
|
errors: {},
|
|
processing: false,
|
|
setData(key, value) {
|
|
if (typeof key === 'function')
|
|
this.data = key(this.data);
|
|
else if (typeof key === 'object') this.data = key;
|
|
else this.data[key] = value;
|
|
},
|
|
clearErrors() {},
|
|
setDefaults() {},
|
|
transform(callback) {
|
|
this.transformer = callback;
|
|
},
|
|
post(url, options) {
|
|
sent.push({
|
|
url,
|
|
options,
|
|
data: this.transformer
|
|
? this.transformer(this.data)
|
|
: this.data,
|
|
});
|
|
},
|
|
patch(url, options) {
|
|
sent.push({ url, options, data: this.data });
|
|
},
|
|
};
|
|
return forms[i];
|
|
},
|
|
},
|
|
'@/pages/investment/shared': {
|
|
Frame: 'main',
|
|
card: '',
|
|
input: '',
|
|
button: '',
|
|
},
|
|
'@/lib/operation-key': {
|
|
createOperationKey: () => 'synthetic-operation',
|
|
},
|
|
'@/lib/desk-field-values': load(
|
|
'../../resources/js/lib/desk-field-values.ts',
|
|
),
|
|
'@/lib/desk-field-visibility': load(
|
|
'../../resources/js/lib/desk-field-visibility.ts',
|
|
),
|
|
'@/components/dossier-prefill': { DossierPrefill: Empty },
|
|
'@/components/desk-typed-choice': { default: Empty },
|
|
'@/components/desk-draft-recovery': { default: Empty },
|
|
'@/components/desk-attachment-field': { default: Empty },
|
|
'@/components/country-select': { default: Empty },
|
|
'@/components/investment-territory': { TerritoryFields: Empty },
|
|
'@/components/desk-internal-operations': {
|
|
DocumentReview: Empty,
|
|
RequestOperations: Empty,
|
|
},
|
|
'@/components/investor-guidance': { InvestorGuidance: Empty },
|
|
'@/components/desk-case-state-form': { default: Empty },
|
|
'@/components/desk-document-picker': { default: Empty },
|
|
...extra,
|
|
};
|
|
mocks['@/lib/crm-record-edit'] = load(
|
|
'../../resources/js/lib/crm-record-edit.ts',
|
|
mocks,
|
|
);
|
|
mocks['@/components/crm-edit-comparison'] = { default: Empty };
|
|
mocks['@/components/crm-task-creation'] = { default: Empty };
|
|
const exports = load(file, mocks, suffix);
|
|
const Component =
|
|
exports.TaskRow ?? exports.default ?? exports.InvestorGuidance;
|
|
function render(patch = {}) {
|
|
props = { ...props, ...patch };
|
|
cursor = 0;
|
|
formCursor = 0;
|
|
tree = Component(props);
|
|
}
|
|
render();
|
|
return {
|
|
forms,
|
|
sent,
|
|
render,
|
|
all: () => nodes(tree),
|
|
text: () => text(tree),
|
|
find: (type, label) =>
|
|
nodes(tree).find(
|
|
(n) => n.type === type && (!label || text(n).includes(label)),
|
|
),
|
|
};
|
|
}
|
|
const request = {
|
|
id: 'request-one',
|
|
version: 1,
|
|
status: 'draft',
|
|
payload: { title: 'SYNTHETIC saved title' },
|
|
protocol: null,
|
|
editable_fields: [],
|
|
analyst_id: null,
|
|
due_at: null,
|
|
};
|
|
const props = {
|
|
request,
|
|
case: { id: 'case-one', title: 'SYNTHETIC dossier' },
|
|
definition: {
|
|
title: 'SYNTHETIC service',
|
|
fields: {
|
|
title: { label: 'Project title', type: 'text', required: true },
|
|
},
|
|
result: 'SYNTHETIC',
|
|
},
|
|
owner: true,
|
|
manager: false,
|
|
analyst: false,
|
|
fieldsEditable: true,
|
|
operationsEditable: true,
|
|
states: {
|
|
draft: 'Rascunho',
|
|
submitted: 'Submetido',
|
|
reviewing: 'Em análise',
|
|
},
|
|
transitions: [],
|
|
findings: [],
|
|
messages: [],
|
|
analysts: [],
|
|
territoryPaths: {},
|
|
events: [],
|
|
documents: [],
|
|
payments: [],
|
|
caseVersion: 1,
|
|
countries: [],
|
|
toolPrefill: null,
|
|
};
|
|
const page = (patch) =>
|
|
harness('../../resources/js/pages/desk/request.tsx', {
|
|
...props,
|
|
...patch,
|
|
});
|
|
|
|
test('owner cancellation uses the existing transition and restores saved server payload only after success', () => {
|
|
const h = page();
|
|
h.forms[0].setData('payload', { title: 'SYNTHETIC unsaved edit' });
|
|
h.forms[1].setData('reason', 'SYNTHETIC deliberate cancellation');
|
|
h.find('form', 'Cancelar este pedido').props.onSubmit({
|
|
preventDefault() {},
|
|
});
|
|
assert.equal(h.sent[0].data.status, 'cancelled');
|
|
assert.equal(h.sent[0].data.version, 1);
|
|
assert.equal(h.sent[0].data.reason, 'SYNTHETIC deliberate cancellation');
|
|
assert.equal(h.forms[0].data.payload.title, 'SYNTHETIC unsaved edit');
|
|
h.sent[0].options.onSuccess({
|
|
props: { request: { ...request, status: 'cancelled', version: 2 } },
|
|
});
|
|
h.render({
|
|
request: { ...request, status: 'cancelled', version: 2 },
|
|
fieldsEditable: false,
|
|
operationsEditable: false,
|
|
});
|
|
assert.equal(h.forms[0].data.payload.title, 'SYNTHETIC saved title');
|
|
assert.equal(h.forms[0].data.consent, false);
|
|
assert.equal(h.find('form', 'Cancelar este pedido'), undefined);
|
|
});
|
|
|
|
test('cancellation waits for recovery work and is unavailable to submitted requests, reviewers and closed dossiers', () => {
|
|
const Recovery = () => null;
|
|
const h = harness('../../resources/js/pages/desk/request.tsx', props, {
|
|
'@/components/desk-draft-recovery': { default: Recovery },
|
|
});
|
|
h.all()
|
|
.find((n) => n.type === Recovery)
|
|
.props.onBusy(true);
|
|
h.render();
|
|
assert.equal(
|
|
h.find('button', 'Confirmar cancelamento').props.disabled,
|
|
true,
|
|
);
|
|
h.find('form', 'Cancelar este pedido').props.onSubmit({
|
|
preventDefault() {},
|
|
});
|
|
assert.equal(h.sent.length, 0);
|
|
for (const patch of [
|
|
{ request: { ...request, status: 'submitted' } },
|
|
{ owner: false, manager: true },
|
|
{ operationsEditable: false },
|
|
])
|
|
assert.equal(
|
|
page(patch).find('form', 'Cancelar este pedido'),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test('closed dossier request hides response, assignment and message actions while keeping previous messages visible', () => {
|
|
const open = {
|
|
request: { ...request, status: 'submitted' },
|
|
owner: false,
|
|
manager: true,
|
|
fieldsEditable: false,
|
|
transitions: ['reviewing'],
|
|
analysts: [{ id: 2, name: 'SYNTHETIC analyst' }],
|
|
messages: [
|
|
{
|
|
id: 'message-one',
|
|
body: 'SYNTHETIC historical message',
|
|
name: 'SYNTHETIC author',
|
|
kind: 'responsible_response',
|
|
created_at: '2026-11-03',
|
|
},
|
|
],
|
|
};
|
|
const h = page({ ...open, operationsEditable: false });
|
|
assert.match(h.text(), /SYNTHETIC historical message/);
|
|
assert.equal(h.find('button', 'Atribuir acompanhamento'), undefined);
|
|
assert.equal(
|
|
h.find('form', 'Resposta / análise do responsável'),
|
|
undefined,
|
|
);
|
|
assert.equal(h.find('form', 'Mensagem privada'), undefined);
|
|
assert.ok(page(open).find('form', 'Resposta / análise do responsável'));
|
|
assert.ok(page(open).find('form', 'Mensagem privada'));
|
|
});
|
|
|
|
test('correction explanation applies to returned requests and does not ask draft authors an inapplicable question', () => {
|
|
assert.doesNotMatch(page().text(), /Justificação de correcção/);
|
|
assert.match(
|
|
page({
|
|
request: {
|
|
...request,
|
|
status: 'returned',
|
|
editable_fields: ['title'],
|
|
},
|
|
}).text(),
|
|
/Justificação de correcção/,
|
|
);
|
|
});
|
|
|
|
test('closed case keeps workflow and documentary context without stage or review operations', () => {
|
|
const Review = () => null;
|
|
const base = {
|
|
case: {
|
|
id: 'case-one',
|
|
title: 'SYNTHETIC dossier',
|
|
version: 1,
|
|
closed_at: '2026-11-03',
|
|
workflow: {},
|
|
},
|
|
canWrite: false,
|
|
manager: true,
|
|
requests: [],
|
|
services: [],
|
|
documents: [],
|
|
workflow: {},
|
|
stepStates: {},
|
|
states: {},
|
|
guidance: null,
|
|
events: [],
|
|
links: [],
|
|
legacyRequest: null,
|
|
};
|
|
const h = harness('../../resources/js/pages/desk/case.tsx', base, {
|
|
'@/components/desk-internal-operations': { DocumentReview: Review },
|
|
});
|
|
assert.match(h.text(), /Dossier encerrado/);
|
|
assert.equal(h.find('button', 'Registar etapa'), undefined);
|
|
assert.equal(
|
|
h.all().some((n) => n.type === Review),
|
|
false,
|
|
);
|
|
h.render({ case: { ...base.case, closed_at: null } });
|
|
assert.ok(h.find('button', 'Registar etapa'));
|
|
assert.equal(
|
|
h.all().some((n) => n.type === Review),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('CRM linked task preserves its title, status and deadline without an update action when dossier is closed', () => {
|
|
const task = {
|
|
id: 'task-one',
|
|
title: 'SYNTHETIC linked task',
|
|
status: 'open',
|
|
due_at: '2026-11-05T12:00:00Z',
|
|
assignee_user_id: 1,
|
|
version_no: 1,
|
|
can_update: false,
|
|
};
|
|
const h = harness(
|
|
'../../resources/js/pages/crm/lead.tsx',
|
|
{ task, leadId: 'lead-one' },
|
|
{
|
|
'../management/shared': {
|
|
Management: 'main',
|
|
Field: 'label',
|
|
button: '',
|
|
control: '',
|
|
},
|
|
'./types': {
|
|
taskStates: { open: 'Aberta', done: 'Concluída' },
|
|
leadStates: {},
|
|
},
|
|
},
|
|
'\nexport { TaskRow };',
|
|
);
|
|
assert.match(h.text(), /SYNTHETIC linked task/);
|
|
assert.equal(h.find('select').props.disabled, true);
|
|
assert.equal(h.find('button', 'Actualizar tarefa'), undefined);
|
|
h.find('form').props.onSubmit({ preventDefault() {} });
|
|
assert.equal(h.sent.length, 0);
|
|
h.render({ task: { ...task, can_update: true } });
|
|
assert.ok(h.find('button', 'Actualizar tarefa'));
|
|
});
|
|
|
|
test('guidance hides task changes on a closed dossier but keeps task history and consulted sources', () => {
|
|
const guidance = {
|
|
version: 1,
|
|
fingerprint: 'x'.repeat(64),
|
|
rules_version: 'SYNTHETIC',
|
|
computed_at: '2026-11-03',
|
|
can_write: false,
|
|
can_update_tasks: false,
|
|
facts: {},
|
|
financial_draft: null,
|
|
consulted: [],
|
|
unavailable: [],
|
|
catalogue_checks: [],
|
|
phases: [
|
|
{
|
|
id: 'one',
|
|
objective: 'SYNTHETIC phase',
|
|
known: [],
|
|
questions: [],
|
|
evidence: [],
|
|
recommendations: [],
|
|
risks: [],
|
|
},
|
|
],
|
|
tasks: [
|
|
{
|
|
id: 'task-one',
|
|
title: 'SYNTHETIC saved task',
|
|
status: 'open',
|
|
due_at: '2026-11-05',
|
|
assignee_user_id: 1,
|
|
assignee_name: 'SYNTHETIC actor',
|
|
version_no: 1,
|
|
accepted_at: '2026-11-03',
|
|
desk_request_id: null,
|
|
},
|
|
],
|
|
calculator_versions: {},
|
|
crm: { reason: 'SYNTHETIC internal context' },
|
|
};
|
|
const h = harness(
|
|
'../../resources/js/components/investor-guidance.tsx',
|
|
{
|
|
caseId: 'case-one',
|
|
guidance,
|
|
manager: true,
|
|
actorId: 1,
|
|
requests: [],
|
|
},
|
|
{
|
|
'@/lib/investor-guidance-finance': {
|
|
guidanceFinance: () => ({
|
|
reason: 'SYNTHETIC no financial draft',
|
|
result: null,
|
|
}),
|
|
guidanceFinancialTools: () => null,
|
|
},
|
|
},
|
|
);
|
|
assert.match(h.text(), /SYNTHETIC saved task/);
|
|
assert.doesNotMatch(h.text(), /Resultado ou motivo/);
|
|
h.render({ guidance: { ...guidance, can_update_tasks: true } });
|
|
assert.match(h.text(), /Resultado ou motivo/);
|
|
});
|