570 lines
19 KiB
JavaScript
570 lines
19 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 { File } from 'node:buffer';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import ts from 'typescript';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
function load(file, mocks = {}, globals = {}, suffix = '') {
|
|
const output = ts.transpileModule(
|
|
fs.readFileSync(new URL(file, import.meta.url), 'utf8') + suffix,
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
jsx: ts.JsxEmit.ReactJSX,
|
|
},
|
|
},
|
|
).outputText;
|
|
const module = { exports: {} };
|
|
compileFunction(output, [
|
|
'require',
|
|
'module',
|
|
'exports',
|
|
...Object.keys(globals),
|
|
])(
|
|
(name) => mocks[name] ?? require(name),
|
|
module,
|
|
module.exports,
|
|
...Object.values(globals),
|
|
);
|
|
return module.exports;
|
|
}
|
|
const values = load('../../resources/js/lib/desk-field-values.ts');
|
|
function nodes(tree) {
|
|
if (Array.isArray(tree)) return tree.flatMap(nodes);
|
|
if (!tree || typeof tree !== 'object') return [];
|
|
return [tree, ...nodes(tree.props?.children)];
|
|
}
|
|
const field = {
|
|
type: 'attachment',
|
|
label: 'SYNTHETIC support',
|
|
required: true,
|
|
mime_types: ['text/plain'],
|
|
max_size_kb: 2,
|
|
max_files: 1,
|
|
};
|
|
const document = {
|
|
id: 'link-one',
|
|
document_id: 'document-one',
|
|
file_name: 'SYNTHETIC proof.txt',
|
|
category: 'SYNTHETIC',
|
|
source_reference: 'SYNTHETIC source',
|
|
status: 'quarantined',
|
|
malware_scan_status: 'pending',
|
|
media_type: 'text/plain',
|
|
size_bytes: 100,
|
|
version_no: 1,
|
|
};
|
|
function fieldHarness(
|
|
handler = async () => ({
|
|
documents: [document],
|
|
case_version: 2,
|
|
link_id: document.id,
|
|
}),
|
|
) {
|
|
const cells = [],
|
|
calls = [],
|
|
inventory = [],
|
|
busy = [],
|
|
changes = [];
|
|
let cursor = 0,
|
|
props,
|
|
tree;
|
|
const 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];
|
|
},
|
|
};
|
|
class ValidationError extends Error {}
|
|
const Component = load(
|
|
'../../resources/js/components/desk-attachment-field.tsx',
|
|
{
|
|
react,
|
|
'@/lib/profile-json': {
|
|
ProfileValidationError: ValidationError,
|
|
async profileJson(url, body = {}, method = 'POST') {
|
|
calls.push({ url, body, method });
|
|
return handler(url, body, method);
|
|
},
|
|
},
|
|
'@/lib/desk-field-values': values,
|
|
'./desk-document-picker': { default: () => null },
|
|
},
|
|
).default;
|
|
props = {
|
|
caseId: 'synthetic-case',
|
|
requestId: 'synthetic-request',
|
|
caseVersion: 1,
|
|
fieldKey: 'support',
|
|
field,
|
|
value: [],
|
|
documents: [document],
|
|
disabled: false,
|
|
uploading: false,
|
|
onChange: (ids) => changes.push(ids),
|
|
onBusy: (value) => busy.push(value),
|
|
onInventory: (data, select) => inventory.push({ data, select }),
|
|
};
|
|
const render = (patch = {}) => {
|
|
props = { ...props, ...patch };
|
|
cursor = 0;
|
|
tree = Component(props);
|
|
return tree;
|
|
};
|
|
render();
|
|
return {
|
|
render,
|
|
calls,
|
|
inventory,
|
|
busy,
|
|
changes,
|
|
async settle() {
|
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
render();
|
|
},
|
|
get tree() {
|
|
return tree;
|
|
},
|
|
button(label) {
|
|
return nodes(tree).find(
|
|
(node) =>
|
|
node.type === 'button' && node.props.children === label,
|
|
);
|
|
},
|
|
prepare(
|
|
file = new File(['SYNTHETIC bytes'], 'synthetic-proof.txt', {
|
|
type: 'text/plain',
|
|
}),
|
|
) {
|
|
nodes(tree)
|
|
.find(
|
|
(node) =>
|
|
node.type === 'input' && node.props.type === 'file',
|
|
)
|
|
.props.onChange({ target: { files: [file] } });
|
|
render();
|
|
nodes(tree)
|
|
.find(
|
|
(node) =>
|
|
node.type === 'input' && node.props.maxLength === 3000,
|
|
)
|
|
.props.onChange({
|
|
target: { value: 'SYNTHETIC submitted source' },
|
|
});
|
|
render();
|
|
nodes(tree)
|
|
.filter(
|
|
(node) =>
|
|
node.type === 'input' && node.props.type === 'checkbox',
|
|
)
|
|
.at(-1)
|
|
.props.onChange({ target: { checked: true } });
|
|
render();
|
|
},
|
|
};
|
|
}
|
|
|
|
await test('cross-dossier picker uses the selected origin identity and does not re-enter existing provenance', async () => {
|
|
const h = fieldHarness();
|
|
const picker = nodes(h.tree).find(
|
|
(node) => typeof node.type === 'function' && 'onSelect' in node.props,
|
|
);
|
|
picker.props.onSelect({
|
|
...document,
|
|
id: 'other-dossier-link',
|
|
document_id: 'other-original',
|
|
origin_label: 'SYNTHETIC previous dossier',
|
|
});
|
|
h.render();
|
|
assert.ok(
|
|
!nodes(h.tree).some(
|
|
(node) => node.type === 'input' && node.props.type === 'file',
|
|
),
|
|
);
|
|
assert.ok(
|
|
!nodes(h.tree).some(
|
|
(node) => node.type === 'input' && node.props.maxLength === 3000,
|
|
),
|
|
);
|
|
nodes(h.tree)
|
|
.filter(
|
|
(node) => node.type === 'input' && node.props.type === 'checkbox',
|
|
)
|
|
.at(-1)
|
|
.props.onChange({ target: { checked: true } });
|
|
h.render();
|
|
h.button('Reutilizar documento nesta pergunta').props.onClick();
|
|
await h.settle();
|
|
assert.equal(h.calls[0].body.get('document_id'), 'other-original');
|
|
assert.equal(h.calls[0].body.get('reuse_link_id'), 'other-dossier-link');
|
|
assert.equal(h.calls[0].body.has('source_reference'), false);
|
|
assert.equal(h.calls[0].body.has('file'), false);
|
|
assert.equal(h.inventory[0].select, true);
|
|
});
|
|
|
|
await test('attachment question reuses labelled documents, preserves pending drafts and enforces configured choice/format limits', () => {
|
|
const h = fieldHarness();
|
|
let tree = h.tree;
|
|
assert.equal(
|
|
nodes(tree).filter(
|
|
(node) => node.type === 'input' && node.props.type === 'checkbox',
|
|
)[0].props.disabled,
|
|
false,
|
|
);
|
|
nodes(tree)
|
|
.filter(
|
|
(node) => node.type === 'input' && node.props.type === 'checkbox',
|
|
)[0]
|
|
.props.onChange({ target: { checked: true } });
|
|
assert.deepEqual(h.changes[0], ['link-one']);
|
|
const html = renderToStaticMarkup(tree);
|
|
assert.match(html, /SYNTHETIC proof.txt/);
|
|
assert.match(html, /Análise ou disponibilidade pendente/);
|
|
assert.match(html, /2 KB por ficheiro/);
|
|
assert.ok(
|
|
!nodes(tree).some(
|
|
(node) =>
|
|
node.type === 'input' && node.props.name === 'document_id',
|
|
),
|
|
);
|
|
tree = h.render({
|
|
value: ['link-one'],
|
|
documents: [
|
|
document,
|
|
{
|
|
...document,
|
|
id: 'link-two',
|
|
document_id: 'document-two',
|
|
file_name: 'SYNTHETIC other.txt',
|
|
},
|
|
],
|
|
});
|
|
assert.equal(
|
|
nodes(tree).filter(
|
|
(node) => node.type === 'input' && node.props.type === 'checkbox',
|
|
)[1].props.disabled,
|
|
true,
|
|
);
|
|
tree = h.render({
|
|
value: [],
|
|
documents: [{ ...document, media_type: 'application/pdf' }],
|
|
});
|
|
assert.equal(
|
|
nodes(tree).find(
|
|
(node) => node.type === 'input' && node.props.type === 'checkbox',
|
|
).props.disabled,
|
|
true,
|
|
);
|
|
tree = h.render({
|
|
value: [],
|
|
documents: [{ ...document, media_type: null, size_bytes: null }],
|
|
});
|
|
assert.equal(
|
|
nodes(tree).find(
|
|
(node) => node.type === 'input' && node.props.type === 'checkbox',
|
|
).props.disabled,
|
|
false,
|
|
);
|
|
assert.match(renderToStaticMarkup(tree), /Formato ou tamanho por conferir/);
|
|
assert.equal(h.render({ disabled: true }).props.disabled, true);
|
|
});
|
|
|
|
await test('document aliases render once and removing a choice removes duplicate aliases without changing another question', () => {
|
|
const h = fieldHarness();
|
|
const tree = h.render({
|
|
value: ['link-one', 'link-alias'],
|
|
documents: [document, { ...document, id: 'link-alias' }],
|
|
});
|
|
const checkboxes = nodes(tree).filter(
|
|
(node) => node.type === 'input' && node.props.type === 'checkbox',
|
|
);
|
|
assert.equal(checkboxes.length, 2); // One document and upload consent.
|
|
checkboxes[0].props.onChange({ target: { checked: false } });
|
|
assert.deepEqual(h.changes[0], []);
|
|
const inherited = values.mergeDeskPrefill(
|
|
{ support: [] },
|
|
{ support: ['link-one'] },
|
|
{ support: field },
|
|
true,
|
|
);
|
|
assert.deepEqual(inherited.support, []);
|
|
});
|
|
|
|
await test('new support uses multipart with request, question and case version and never submits the request', async () => {
|
|
const h = fieldHarness();
|
|
h.prepare();
|
|
const fileInput = nodes(h.tree).find(
|
|
(node) => node.type === 'input' && node.props.type === 'file',
|
|
);
|
|
fileInput.props.ref.current = { value: 'selected' };
|
|
await h.button('Associar ficheiro a esta pergunta').props.onClick();
|
|
await h.settle();
|
|
assert.equal(h.calls.length, 1);
|
|
const data = h.calls[0].body;
|
|
assert.ok(data instanceof FormData);
|
|
assert.equal(data.get('field_key'), 'support');
|
|
assert.equal(data.get('request_id'), 'synthetic-request');
|
|
assert.equal(data.get('version'), '1');
|
|
assert.equal(data.get('source_reference'), 'SYNTHETIC submitted source');
|
|
assert.equal(data.get('submit'), null);
|
|
assert.equal(data.get('context_reviewed'), null);
|
|
assert.equal(h.inventory[0].select, true);
|
|
assert.deepEqual(h.busy, [true, false]);
|
|
assert.equal(fileInput.props.ref.current.value, '');
|
|
assert.equal(
|
|
h.button('Associar ficheiro a esta pergunta').props.disabled,
|
|
true,
|
|
);
|
|
});
|
|
|
|
await test('oversized file stays selected without a write and a failed upload can be retried with the original data', async () => {
|
|
const oversized = fieldHarness();
|
|
oversized.prepare(
|
|
new File(['X'.repeat(2049)], 'oversized.txt', { type: 'text/plain' }),
|
|
);
|
|
await oversized.button('Associar ficheiro a esta pergunta').props.onClick();
|
|
oversized.render();
|
|
assert.equal(oversized.calls.length, 0);
|
|
assert.match(
|
|
renderToStaticMarkup(oversized.tree),
|
|
/excede o tamanho máximo/,
|
|
);
|
|
let failed = true;
|
|
const h = fieldHarness(async () => {
|
|
if (failed) throw Error('SYNTHETIC network failure');
|
|
return {
|
|
documents: [document],
|
|
case_version: 2,
|
|
link_id: document.id,
|
|
reused_link: true,
|
|
};
|
|
});
|
|
h.prepare();
|
|
await h.button('Associar ficheiro a esta pergunta').props.onClick();
|
|
await h.settle();
|
|
assert.equal(
|
|
h.button('Associar ficheiro a esta pergunta').props.disabled,
|
|
false,
|
|
);
|
|
failed = false;
|
|
await h.button('Associar ficheiro a esta pergunta').props.onClick();
|
|
await h.settle();
|
|
assert.equal(h.calls.length, 2);
|
|
assert.equal(
|
|
h.calls[0].body.get('file').size,
|
|
h.calls[1].body.get('file').size,
|
|
);
|
|
assert.match(
|
|
renderToStaticMarkup(h.tree),
|
|
/Conservou-se a referência registada/,
|
|
);
|
|
});
|
|
|
|
await test('document refresh keeps answers on the page and a stale upload response is not treated as a confirmed selection', async () => {
|
|
const h = fieldHarness(async (url, body, method) =>
|
|
method === 'GET'
|
|
? { documents: [document], caseVersion: 8 }
|
|
: { ok: true },
|
|
);
|
|
await h.button('Actualizar documentos disponíveis').props.onClick();
|
|
await h.settle();
|
|
assert.equal(h.inventory[0].select, false);
|
|
assert.equal(h.inventory[0].data.case_version, 8);
|
|
assert.equal(h.changes.length, 0);
|
|
h.prepare();
|
|
await h.button('Associar ficheiro a esta pergunta').props.onClick();
|
|
await h.settle();
|
|
assert.equal(h.inventory.length, 1);
|
|
assert.match(renderToStaticMarkup(h.tree), /não confirmou o documento/);
|
|
assert.equal(
|
|
h.button('Associar ficheiro a esta pergunta').props.disabled,
|
|
false,
|
|
);
|
|
});
|
|
|
|
await test('the common transport keeps multipart boundaries with the browser and preserves existing JSON and CSRF behaviour', async () => {
|
|
const sent = [];
|
|
const api = load(
|
|
'../../resources/js/lib/profile-json.ts',
|
|
{},
|
|
{
|
|
document: { cookie: 'XSRF-TOKEN=synthetic%3Dtoken' },
|
|
fetch: async (url, options) => {
|
|
sent.push(options);
|
|
return new Response(JSON.stringify({ ok: true }), {
|
|
status: 200,
|
|
});
|
|
},
|
|
},
|
|
);
|
|
const data = new FormData();
|
|
data.set(
|
|
'file',
|
|
new File(['SYNTHETIC'], 'proof.txt', { type: 'text/plain' }),
|
|
);
|
|
await api.profileJson('/synthetic-upload', data);
|
|
assert.equal(sent[0].body, data);
|
|
assert.equal(sent[0].headers['Content-Type'], undefined);
|
|
assert.equal(sent[0].headers['X-XSRF-TOKEN'], 'synthetic=token');
|
|
assert.equal(sent[0].credentials, 'same-origin');
|
|
await api.profileJson('/synthetic-json', { answer: 0 });
|
|
assert.equal(sent[1].body, '{"answer":0}');
|
|
assert.equal(sent[1].headers['Content-Type'], 'application/json');
|
|
});
|
|
|
|
await test('owner reuse chooses the precise registered source without asking for its reference again or sending another file', async () => {
|
|
const sourceB = {
|
|
...document,
|
|
id: 'source-b',
|
|
document_id: 'shared-original',
|
|
source_reference: 'SYNTHETIC ref B',
|
|
stage: 'E1',
|
|
valid_until: '2027-01-02',
|
|
};
|
|
const sourceC = {
|
|
...sourceB,
|
|
id: 'source-c',
|
|
source_reference: 'SYNTHETIC ref C',
|
|
stage: 'E2',
|
|
};
|
|
const target = { ...sourceB, id: 'new-use' };
|
|
const h = fieldHarness(async () => ({
|
|
documents: [target],
|
|
case_version: 4,
|
|
link_id: target.id,
|
|
reusable_documents: [sourceB, sourceC, target],
|
|
}));
|
|
h.render({ documents: [], reusableDocuments: [sourceB, sourceC] });
|
|
let select = nodes(h.tree).find((node) => node.type === 'select');
|
|
assert.equal(
|
|
nodes(select).filter((node) => node.type === 'option').length,
|
|
3,
|
|
);
|
|
select.props.onChange({ target: { value: 'source-b' } });
|
|
h.render();
|
|
assert.ok(
|
|
!nodes(h.tree).some(
|
|
(node) => node.type === 'input' && node.props.type === 'file',
|
|
),
|
|
);
|
|
assert.ok(
|
|
!nodes(h.tree).some(
|
|
(node) => node.type === 'input' && node.props.maxLength === 3000,
|
|
),
|
|
);
|
|
assert.match(
|
|
renderToStaticMarkup(h.tree),
|
|
/Referência já registada: SYNTHETIC ref B/,
|
|
);
|
|
nodes(h.tree)
|
|
.find((node) => node.type === 'input' && node.props.type === 'checkbox')
|
|
.props.onChange({ target: { checked: true } });
|
|
h.render();
|
|
await h.button('Reutilizar documento nesta pergunta').props.onClick();
|
|
await h.settle();
|
|
const data = h.calls[0].body;
|
|
assert.equal(data.get('document_id'), 'shared-original');
|
|
assert.equal(data.get('reuse_link_id'), 'source-b');
|
|
assert.equal(data.get('source_reference'), null);
|
|
assert.equal(data.get('file'), null);
|
|
assert.equal(h.inventory[0].select, true);
|
|
});
|
|
|
|
await test('editor publishes explicit attachment limits in the proposed version and clears unrelated metadata when type changes', () => {
|
|
let form;
|
|
const Editor = load(
|
|
'../../resources/js/pages/desk/service-editor.tsx',
|
|
{
|
|
react: { useState: (value) => [value, () => {}] },
|
|
'@inertiajs/react': {
|
|
Link: 'a',
|
|
useForm(key, initial) {
|
|
form ??= {
|
|
data: initial,
|
|
errors: {},
|
|
processing: false,
|
|
setData(value) {
|
|
this.data = value(this.data);
|
|
},
|
|
};
|
|
return form;
|
|
},
|
|
},
|
|
'@/pages/investment/shared': {
|
|
Frame: 'main',
|
|
card: '',
|
|
input: '',
|
|
button: '',
|
|
},
|
|
'@/components/desk-service-history': { default: () => null },
|
|
},
|
|
{},
|
|
'\nexport { Editor };',
|
|
).Editor;
|
|
const service = {
|
|
code: 'synthetic',
|
|
version: 1,
|
|
definition: {
|
|
title: 'SYNTHETIC',
|
|
theme: 'Synthetic',
|
|
profile: 'Investor',
|
|
entity: 'Internal',
|
|
result: 'Internal preparation',
|
|
stage: 'E10',
|
|
fields: {
|
|
question: {
|
|
label: 'SYNTHETIC support',
|
|
type: 'text',
|
|
required: false,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const render = () => Editor({ service });
|
|
let tree = render();
|
|
const type = () =>
|
|
nodes(tree).find(
|
|
(node) =>
|
|
node.type === 'select' &&
|
|
nodes(node).some(
|
|
(child) =>
|
|
child.type === 'option' &&
|
|
child.props.value === 'attachment',
|
|
),
|
|
);
|
|
type().props.onChange({ target: { value: 'attachment' } });
|
|
tree = render();
|
|
assert.equal(form.data.definition.fields.question.max_files, 1);
|
|
assert.equal(form.data.definition.fields.question.max_size_kb, 10240);
|
|
assert.equal(form.data.definition.fields.question.mime_types.length, 4);
|
|
assert.equal(form.data.definition.fields.question.required, false);
|
|
const size = nodes(tree).find(
|
|
(node) => node.type === 'input' && node.props.max === 10240,
|
|
);
|
|
size.props.onChange({ target: { value: '256' } });
|
|
tree = render();
|
|
assert.equal(form.data.definition.fields.question.max_size_kb, 256);
|
|
assert.equal(form.data.confirm, false);
|
|
assert.match(renderToStaticMarkup(tree), /Tamanho por ficheiro: 256 KiB/);
|
|
type().props.onChange({ target: { value: 'text' } });
|
|
assert.equal(form.data.definition.fields.question.mime_types, undefined);
|
|
assert.equal(form.data.definition.fields.question.max_files, undefined);
|
|
assert.equal(service.definition.fields.question.type, 'text');
|
|
});
|