496 lines
16 KiB
JavaScript
496 lines
16 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) {
|
|
const output = ts.transpileModule(
|
|
fs.readFileSync(new URL(file, import.meta.url), 'utf8'),
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
jsx: ts.JsxEmit.ReactJSX,
|
|
},
|
|
},
|
|
).outputText;
|
|
const module = { exports: {} };
|
|
compileFunction(output, ['require', 'module', 'exports'])(
|
|
(name) => mocks[name] ?? require(name),
|
|
module,
|
|
module.exports,
|
|
);
|
|
return module.exports.default;
|
|
}
|
|
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 = (node) =>
|
|
typeof node === 'string'
|
|
? node
|
|
: Array.isArray(node)
|
|
? node.map(text).join('')
|
|
: node?.props
|
|
? text(node.props.children)
|
|
: typeof node === 'number'
|
|
? String(node)
|
|
: '';
|
|
const document = {
|
|
id: 'source-one',
|
|
document_id: 'original-one',
|
|
file_name: 'SYNTHETIC source.txt',
|
|
version_no: 3,
|
|
source_reference: 'SYNTHETIC exact source',
|
|
category: 'project_evidence',
|
|
stage: 'E2',
|
|
valid_until: '2028-01-02',
|
|
origin_label: 'SYNTHETIC previous dossier',
|
|
status: 'active',
|
|
malware_scan_status: 'clean',
|
|
media_type: 'text/plain',
|
|
size_bytes: 100,
|
|
};
|
|
function harness(handler) {
|
|
const cells = [],
|
|
calls = [],
|
|
selections = [];
|
|
let cursor = 0,
|
|
props,
|
|
tree;
|
|
const Component = load(
|
|
'../../resources/js/components/desk-document-picker.tsx',
|
|
{
|
|
react: {
|
|
useId: () => 'synthetic-picker',
|
|
useRef(initial) {
|
|
const i = cursor++;
|
|
cells[i] ??= { current: initial };
|
|
return cells[i];
|
|
},
|
|
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;
|
|
},
|
|
];
|
|
},
|
|
},
|
|
'@/lib/profile-json': {
|
|
async profileJson(url, body, method) {
|
|
calls.push({ url, body, method });
|
|
return handler(url);
|
|
},
|
|
},
|
|
},
|
|
);
|
|
props = {
|
|
caseId: 'synthetic-case',
|
|
disabled: false,
|
|
selected: null,
|
|
onSelect: (doc) => {
|
|
selections.push(doc);
|
|
props.selected = doc;
|
|
},
|
|
};
|
|
const render = (updates) => {
|
|
props = { ...props, ...updates };
|
|
cursor = 0;
|
|
tree = Component(props);
|
|
return tree;
|
|
};
|
|
const find = (type, label) =>
|
|
nodes(tree).find(
|
|
(n) => n.type === type && (!label || text(n).includes(label)),
|
|
);
|
|
render();
|
|
return {
|
|
render,
|
|
find,
|
|
all: () => nodes(tree),
|
|
calls,
|
|
selections,
|
|
text: () => text(tree),
|
|
};
|
|
}
|
|
const settle = async () => {
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
};
|
|
|
|
test('search encodes literal input, returns names/provenance and never associates on search or selection', async () => {
|
|
const h = harness(async () => ({
|
|
documents: [document],
|
|
next_offset: null,
|
|
}));
|
|
h.find('input').props.onChange({
|
|
target: { value: '%_ source & dossier' },
|
|
});
|
|
h.render();
|
|
h.find('button', 'Pesquisar documentos').props.onClick();
|
|
await settle();
|
|
h.render();
|
|
assert.equal(h.calls.length, 1);
|
|
assert.equal(h.calls[0].method, 'GET');
|
|
const params = new URL(h.calls[0].url, 'http://localhost').searchParams;
|
|
assert.equal(params.get('q'), '%_ source & dossier');
|
|
assert.equal(params.get('offset'), '0');
|
|
assert.match(
|
|
h.text(),
|
|
/SYNTHETIC source\.txt.*versão 3.*SYNTHETIC previous dossier.*exact source.*2028-01-02/,
|
|
);
|
|
h.all()
|
|
.find((n) => n.type === 'input' && n.props.type === 'radio')
|
|
.props.onChange();
|
|
h.render();
|
|
assert.deepEqual(h.selections, [document]);
|
|
assert.equal(h.calls.length, 1);
|
|
assert.match(h.text(), /Origem seleccionada/);
|
|
h.find('button', 'Retirar origem seleccionada').props.onClick();
|
|
assert.equal(h.selections.at(-1), null);
|
|
});
|
|
|
|
test('pagination does not erase a selected source and new searches start at zero', async () => {
|
|
const h = harness(async (url) => ({
|
|
documents: [document],
|
|
next_offset: url.includes('offset=0') ? 25 : null,
|
|
}));
|
|
h.render({ selected: document });
|
|
h.find('button', 'Pesquisar documentos').props.onClick();
|
|
await settle();
|
|
h.render();
|
|
h.find('button', 'Próxima página').props.onClick();
|
|
await settle();
|
|
h.render();
|
|
assert.equal(
|
|
new URL(h.calls.at(-1).url, 'http://localhost').searchParams.get(
|
|
'offset',
|
|
),
|
|
'25',
|
|
);
|
|
assert.match(h.text(), /Origem seleccionada/);
|
|
h.find('button', 'Pesquisar documentos').props.onClick();
|
|
await settle();
|
|
h.render();
|
|
assert.equal(
|
|
new URL(h.calls.at(-1).url, 'http://localhost').searchParams.get(
|
|
'offset',
|
|
),
|
|
'0',
|
|
);
|
|
assert.equal(h.selections.length, 0);
|
|
});
|
|
|
|
test('failed or malformed responses retain selected source and do not expose partial replacements', async () => {
|
|
let failed = true;
|
|
const h = harness(async () => {
|
|
if (failed) throw Error('SYNTHETIC denied');
|
|
return { documents: {}, next_offset: null };
|
|
});
|
|
h.render({ selected: document });
|
|
h.find('button', 'Pesquisar documentos').props.onClick();
|
|
await settle();
|
|
h.render();
|
|
assert.match(h.text(), /SYNTHETIC denied/);
|
|
assert.match(h.text(), /Origem seleccionada/);
|
|
failed = false;
|
|
h.find('button', 'Pesquisar documentos').props.onClick();
|
|
await settle();
|
|
h.render();
|
|
assert.match(h.text(), /selecção anterior foi conservada/);
|
|
assert.equal(h.selections.length, 0);
|
|
});
|
|
|
|
test('editing the search removes the old page cursor while retaining a deliberate document selection', async () => {
|
|
const h = harness(async () => ({ documents: [document], next_offset: 25 }));
|
|
h.render({ selected: document });
|
|
h.find('button', 'Pesquisar documentos').props.onClick();
|
|
await settle();
|
|
h.render();
|
|
assert.ok(h.find('button', 'Próxima página'));
|
|
h.find('input').props.onChange({ target: { value: 'different source' } });
|
|
h.render();
|
|
assert.equal(h.find('button', 'Próxima página'), undefined);
|
|
assert.match(h.text(), /Pesquisa alterada/);
|
|
assert.match(h.text(), /Origem seleccionada/);
|
|
assert.equal(h.selections.length, 0);
|
|
});
|
|
|
|
test('search pending prevents duplicate requests and stale dossier responses are ignored', async () => {
|
|
let resolve;
|
|
const h = harness(
|
|
() =>
|
|
new Promise((r) => {
|
|
resolve = r;
|
|
}),
|
|
);
|
|
h.find('button', 'Pesquisar documentos').props.onClick();
|
|
h.render();
|
|
assert.equal(h.find('button', 'Pesquisar documentos').props.disabled, true);
|
|
h.find('button', 'Pesquisar documentos').props.onClick();
|
|
assert.equal(h.calls.length, 1);
|
|
h.render({ caseId: 'different-case' });
|
|
resolve({ documents: [document], next_offset: null });
|
|
await settle();
|
|
h.render();
|
|
assert.equal(
|
|
h.all().filter((n) => n.type === 'input' && n.props.type === 'radio')
|
|
.length,
|
|
0,
|
|
);
|
|
h.render({ disabled: true });
|
|
h.find('button', 'Pesquisar documentos').props.onClick();
|
|
assert.equal(h.calls.length, 1);
|
|
});
|
|
|
|
test('Enter searches without submitting the surrounding form and an unlinked upload remains selectable', async () => {
|
|
const h = harness(async () => ({
|
|
documents: [
|
|
{
|
|
...document,
|
|
id: null,
|
|
origin_label: null,
|
|
source_reference: null,
|
|
},
|
|
],
|
|
next_offset: null,
|
|
}));
|
|
let prevented = false;
|
|
h.find('input').props.onKeyDown({
|
|
key: 'Enter',
|
|
preventDefault() {
|
|
prevented = true;
|
|
},
|
|
});
|
|
await settle();
|
|
h.render();
|
|
assert.equal(prevented, true);
|
|
assert.match(h.text(), /Ficheiro enviado por si.*Referência por completar/);
|
|
h.all()
|
|
.find((n) => n.type === 'input' && n.props.type === 'radio')
|
|
.props.onChange();
|
|
assert.equal(h.selections[0].id, null);
|
|
assert.equal(h.selections[0].document_id, document.document_id);
|
|
assert.ok(
|
|
h
|
|
.all()
|
|
.filter((n) => n.type === 'button')
|
|
.every((n) => n.props.type === 'button'),
|
|
);
|
|
});
|
|
|
|
test('general and question consumers send chosen source identity without asking for UUIDs or rewriting known source', () => {
|
|
const general = fs.readFileSync(
|
|
new URL('../../resources/js/pages/desk/case.tsx', import.meta.url),
|
|
'utf8',
|
|
);
|
|
const field = fs.readFileSync(
|
|
new URL(
|
|
'../../resources/js/components/desk-attachment-field.tsx',
|
|
import.meta.url,
|
|
),
|
|
'utf8',
|
|
);
|
|
assert.match(general, /<DeskDocumentPicker/);
|
|
assert.doesNotMatch(
|
|
general,
|
|
/Ou identificador de documento|Identificador do documento de suporte/,
|
|
);
|
|
assert.match(general, /document_link_id/);
|
|
assert.match(general, /reuse_link_id: documentOrigin\?\.id/);
|
|
assert.match(
|
|
general,
|
|
/source_reference:\s*documentOrigin\?\.source_reference\?\.trim\(\)/,
|
|
);
|
|
assert.match(field, /<DeskDocumentPicker/);
|
|
assert.match(
|
|
field,
|
|
/if \(origin.id\) data.set\('reuse_link_id', origin.id\)/,
|
|
);
|
|
});
|
|
|
|
function generalHarness() {
|
|
const cells = [],
|
|
forms = [],
|
|
posts = [];
|
|
let cursor = 0,
|
|
formCursor = 0,
|
|
tree;
|
|
const Picker = () => null;
|
|
const Component = load('../../resources/js/pages/desk/case.tsx', {
|
|
react: {
|
|
useRef(initial) {
|
|
const i = cursor++;
|
|
cells[i] ??= { current: initial };
|
|
return cells[i];
|
|
},
|
|
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;
|
|
},
|
|
];
|
|
},
|
|
},
|
|
'@/lib/operation-key': { createOperationKey: () => 'synthetic-key' },
|
|
'@inertiajs/react': {
|
|
Link: 'a',
|
|
router: { reload() {} },
|
|
usePage: () => ({
|
|
props: { auth: { user: { id: 1 } } },
|
|
url: '/balcao-virtual/dossiers/synthetic-case',
|
|
}),
|
|
useForm(initial) {
|
|
const i = formCursor++;
|
|
forms[i] ??= {
|
|
data: { ...initial },
|
|
errors: {},
|
|
processing: false,
|
|
setData(key, value) {
|
|
this.data[key] = value;
|
|
},
|
|
transform(callback) {
|
|
this.transformer = callback;
|
|
},
|
|
post(url, options) {
|
|
posts.push({
|
|
url,
|
|
data: this.transformer
|
|
? this.transformer(this.data)
|
|
: this.data,
|
|
options,
|
|
});
|
|
},
|
|
reset(...keys) {
|
|
for (const key of keys) this.data[key] = initial[key];
|
|
},
|
|
};
|
|
return forms[i];
|
|
},
|
|
},
|
|
'@/pages/investment/shared': {
|
|
Frame: 'main',
|
|
card: '',
|
|
input: '',
|
|
button: '',
|
|
},
|
|
'@/components/investor-guidance': { InvestorGuidance: () => null },
|
|
'@/components/desk-case-state-form': { default: () => null },
|
|
'@/components/desk-internal-operations': { DocumentReview: () => null },
|
|
'@/components/desk-document-picker': { default: Picker },
|
|
});
|
|
const props = {
|
|
case: {
|
|
id: 'synthetic-case',
|
|
title: 'SYNTHETIC dossier',
|
|
version: 7,
|
|
workflow: {},
|
|
payload: {},
|
|
},
|
|
canWrite: true,
|
|
manager: false,
|
|
requests: [],
|
|
services: [],
|
|
documents: [],
|
|
workflow: {},
|
|
stepStates: {},
|
|
states: {},
|
|
guidance: null,
|
|
events: [],
|
|
links: [],
|
|
legacyRequest: null,
|
|
};
|
|
function render() {
|
|
cursor = 0;
|
|
formCursor = 0;
|
|
tree = Component(props);
|
|
}
|
|
render();
|
|
return {
|
|
render,
|
|
forms,
|
|
posts,
|
|
picker: () => nodes(tree).find((n) => n.type === Picker),
|
|
all: () => nodes(tree),
|
|
submit() {
|
|
nodes(tree)
|
|
.find(
|
|
(n) =>
|
|
n.type === 'form' &&
|
|
nodes(n).some((child) => child.type === Picker),
|
|
)
|
|
.props.onSubmit({ preventDefault() {} });
|
|
},
|
|
};
|
|
}
|
|
|
|
test('general association sends the selected link, original version context and consent, preserving selection on failure', () => {
|
|
const h = generalHarness();
|
|
h.forms[2].setData('file', { name: 'SYNTHETIC old local choice' });
|
|
h.forms[2].setData('consent', true);
|
|
h.picker().props.onSelect(document);
|
|
h.render();
|
|
assert.equal(h.forms[2].data.file, null);
|
|
assert.equal(h.forms[2].data.consent, false);
|
|
h.forms[2].setData('consent', true);
|
|
h.submit();
|
|
const posted = h.posts[0];
|
|
assert.equal(posted.data.version, 7);
|
|
assert.equal(posted.data.document_id, document.document_id);
|
|
assert.equal(posted.data.reuse_link_id, document.id);
|
|
assert.equal(posted.data.file, null);
|
|
assert.equal(posted.data.source_reference, undefined);
|
|
h.render();
|
|
assert.equal(h.picker().props.selected.id, document.id);
|
|
posted.options.onSuccess();
|
|
h.render();
|
|
assert.equal(h.picker().props.selected, null);
|
|
assert.equal(h.forms[2].data.consent, false);
|
|
});
|
|
|
|
test('general association asks only for missing provenance on unlinked uploads and preserves entered text until success', () => {
|
|
const h = generalHarness();
|
|
h.picker().props.onSelect({
|
|
...document,
|
|
id: null,
|
|
source_reference: null,
|
|
});
|
|
h.render();
|
|
const source = h.all().find((n) => n.type === 'input' && n.props.required);
|
|
assert.ok(source);
|
|
source.props.onChange({
|
|
target: { value: 'SYNTHETIC missing provenance supplied' },
|
|
});
|
|
h.render();
|
|
h.forms[2].setData('consent', true);
|
|
h.submit();
|
|
assert.equal(h.posts[0].data.document_id, document.document_id);
|
|
assert.equal(h.posts[0].data.reuse_link_id, '');
|
|
assert.equal(
|
|
h.posts[0].data.source_reference,
|
|
'SYNTHETIC missing provenance supplied',
|
|
);
|
|
h.render();
|
|
assert.equal(
|
|
h.forms[2].data.source_reference,
|
|
'SYNTHETIC missing provenance supplied',
|
|
);
|
|
h.posts[0].options.onSuccess();
|
|
assert.equal(h.forms[2].data.source_reference, '');
|
|
});
|