432 lines
14 KiB
JavaScript
432 lines
14 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import { compileFunction } from 'node:vm';
|
|
import { createRequire } from 'node:module';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import ts from 'typescript';
|
|
const require = createRequire(import.meta.url);
|
|
function load(file, mocks = {}) {
|
|
const code = 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(code, ['require', 'module', 'exports'])(
|
|
(key) => mocks[key] ?? require(key),
|
|
module,
|
|
module.exports,
|
|
);
|
|
return module.exports;
|
|
}
|
|
const lib = load('../../resources/js/lib/investment-guides.ts');
|
|
const guide = {
|
|
slug: 'capitalizacao',
|
|
version: 'SYNTHETIC-v1',
|
|
sha256: 'a'.repeat(64),
|
|
title: 'SYNTHETIC capital guide',
|
|
reviewed_on: '2026-11-02',
|
|
scope: 'SYNTHETIC information, not approval',
|
|
instruments: {
|
|
debt: {
|
|
title: 'SYNTHETIC debt',
|
|
description: 'SYNTHETIC repayments',
|
|
risks: ['SYNTHETIC default'],
|
|
prepare: ['SYNTHETIC cashflows'],
|
|
sources: ['primary'],
|
|
},
|
|
equity: {
|
|
title: 'SYNTHETIC equity',
|
|
description: 'SYNTHETIC ownership',
|
|
risks: ['SYNTHETIC dilution'],
|
|
prepare: ['SYNTHETIC ownership'],
|
|
sources: ['primary'],
|
|
},
|
|
},
|
|
sources: {
|
|
primary: {
|
|
title: 'Primary <SYNTHETIC>',
|
|
url: 'https://primary.test/page',
|
|
edition: 'SYNTHETIC edition',
|
|
locator: 'SYNTHETIC paragraph',
|
|
consulted_on: '2026-11-02',
|
|
sha256: 'b'.repeat(64),
|
|
},
|
|
},
|
|
eligibility: [
|
|
{ text: 'SYNTHETIC provider criteria', sources: ['primary'] },
|
|
],
|
|
preparation: [
|
|
{
|
|
title: 'SYNTHETIC documents',
|
|
text: 'SYNTHETIC proposed list',
|
|
sources: ['primary'],
|
|
},
|
|
],
|
|
next: ['SYNTHETIC next action'],
|
|
limits: ['SYNTHETIC no automatic submission'],
|
|
};
|
|
const props = {
|
|
guide,
|
|
instruments: [],
|
|
note: '',
|
|
case: null,
|
|
saved: null,
|
|
canWrite: false,
|
|
historical: false,
|
|
olderEdition: false,
|
|
currentAvailable: true,
|
|
workingCopy: false,
|
|
contextChanged: false,
|
|
unavailableInstruments: [],
|
|
history: [],
|
|
historyNext: null,
|
|
};
|
|
const dossier = {
|
|
id: 'SYNTHETIC-case',
|
|
title: 'SYNTHETIC destination',
|
|
version: 1,
|
|
};
|
|
function nodes(tree) {
|
|
if (!tree || typeof tree !== 'object') return [];
|
|
if (Array.isArray(tree)) return tree.flatMap(nodes);
|
|
return [tree, ...nodes(tree.props?.children)];
|
|
}
|
|
const byName = (tree, name) => nodes(tree).find((n) => n.props?.name === name);
|
|
const button = (tree, label) =>
|
|
nodes(tree).find((n) => n.type === 'button' && n.props.children === label);
|
|
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
function harness() {
|
|
let cursor = 0,
|
|
reject = false,
|
|
withdrawn = false;
|
|
const values = [],
|
|
refs = [],
|
|
requests = [],
|
|
visits = [];
|
|
class ValidationError extends Error {}
|
|
const hooks = {
|
|
useState(initial) {
|
|
const i = cursor++;
|
|
if (!(i in values))
|
|
values[i] = structuredClone(
|
|
typeof initial === 'function' ? initial() : initial,
|
|
);
|
|
return [
|
|
values[i],
|
|
(v) => {
|
|
values[i] = typeof v === 'function' ? v(values[i]) : v;
|
|
},
|
|
];
|
|
},
|
|
useRef(initial) {
|
|
const i = cursor++;
|
|
if (!(i in values)) {
|
|
values[i] = { current: initial };
|
|
refs.push(values[i]);
|
|
}
|
|
return values[i];
|
|
},
|
|
};
|
|
const component = load('../../resources/js/pages/investment/guide.tsx', {
|
|
react: hooks,
|
|
'@inertiajs/react': {
|
|
Link: 'a',
|
|
router: { visit: (...args) => visits.push(args) },
|
|
},
|
|
'./shared': { Frame: 'main', input: '', card: '', button: '' },
|
|
'@/lib/investment-guides': lib,
|
|
'@/lib/operation-key': {
|
|
createOperationKey: () => 'SYNTHETIC-operation',
|
|
},
|
|
'@/lib/profile-json': {
|
|
ProfileValidationError: ValidationError,
|
|
profileJson: async (...args) => {
|
|
requests.push(args);
|
|
if (args[2] === 'GET')
|
|
return {
|
|
...props,
|
|
case: { ...dossier, version: 4 },
|
|
canWrite: true,
|
|
olderEdition: withdrawn,
|
|
currentAvailable: !withdrawn,
|
|
note: 'SYNTHETIC saved by coeditor',
|
|
};
|
|
if (reject) throw new Error('SYNTHETIC conflict');
|
|
return { saved: true };
|
|
},
|
|
},
|
|
});
|
|
return {
|
|
render(extra = {}) {
|
|
cursor = 0;
|
|
const outer = component.default({ ...props, ...extra });
|
|
return outer.type(outer.props);
|
|
},
|
|
refs,
|
|
requests,
|
|
visits,
|
|
fail() {
|
|
reject = true;
|
|
},
|
|
withdraw() {
|
|
withdrawn = true;
|
|
},
|
|
};
|
|
}
|
|
async function withData(data, fn) {
|
|
const original = globalThis.FormData;
|
|
globalThis.FormData = class {
|
|
get(key) {
|
|
return data[key] ?? null;
|
|
}
|
|
getAll(key) {
|
|
return data[key] ?? [];
|
|
}
|
|
};
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
globalThis.FormData = original;
|
|
}
|
|
}
|
|
|
|
await test('public guide distinguishes instruments, risks, provider criteria, documents and exact source edition without identity fields', () => {
|
|
const tree = harness().render(),
|
|
html = renderToStaticMarkup(tree);
|
|
for (const text of [
|
|
'SYNTHETIC debt',
|
|
'SYNTHETIC equity',
|
|
'SYNTHETIC default',
|
|
'SYNTHETIC dilution',
|
|
'SYNTHETIC provider criteria',
|
|
'SYNTHETIC documents',
|
|
'SYNTHETIC next action',
|
|
'SYNTHETIC edition',
|
|
'SYNTHETIC paragraph',
|
|
])
|
|
assert.match(html, new RegExp(text));
|
|
assert.match(html, /Primary <SYNTHETIC>/);
|
|
assert.match(html, /https:\/\/primary.test\/page/);
|
|
assert.equal(byName(tree, 'note'), undefined);
|
|
assert.doesNotMatch(html, /name="(name|email|nuit|amount|return)"/);
|
|
});
|
|
|
|
await test('selection filters only the visible instruments and can return to the whole guide', () => {
|
|
const h = harness();
|
|
const checkbox = nodes(h.render()).find((n) => n.props?.value === 'debt');
|
|
checkbox.props.onChange({ target: { checked: true } });
|
|
let html = renderToStaticMarkup(h.render());
|
|
assert.match(html, /SYNTHETIC default/);
|
|
assert.doesNotMatch(html, /SYNTHETIC dilution/);
|
|
nodes(h.render())
|
|
.find((n) => n.props?.value === 'debt')
|
|
.props.onChange({ target: { checked: false } });
|
|
html = renderToStaticMarkup(h.render());
|
|
assert.match(html, /SYNTHETIC dilution/);
|
|
});
|
|
|
|
await test('authentication URLs carry only coded choices and edition pins, including an explicitly empty selection', async () => {
|
|
const h = harness();
|
|
h.render();
|
|
h.refs[0].current = {};
|
|
await withData(
|
|
{ 'instruments[]': ['equity'], note: 'PRIVATE MUST NOT TRAVEL' },
|
|
async () => {
|
|
nodes(h.render())
|
|
.find((n) => n.type === 'form')
|
|
.props.onSubmit({ preventDefault() {} });
|
|
},
|
|
);
|
|
const url = new URL(h.visits[0][0], 'https://portal.test');
|
|
assert.equal(url.pathname, '/investimentos');
|
|
assert.equal(
|
|
url.searchParams.get('investment_guide[sha256]'),
|
|
guide.sha256,
|
|
);
|
|
assert.deepEqual(
|
|
url.searchParams.getAll('investment_guide[instruments][]'),
|
|
['equity'],
|
|
);
|
|
assert.doesNotMatch(url.href, /PRIVATE|note/);
|
|
const empty = new URL(
|
|
lib.guideUrl('/investimentos/id/guias/capitalizacao', {
|
|
slug: guide.slug,
|
|
sha256: guide.sha256,
|
|
instruments: [],
|
|
}),
|
|
'https://portal.test',
|
|
);
|
|
assert.equal(empty.searchParams.get('current'), '1');
|
|
assert.equal(empty.searchParams.get('selection'), '1');
|
|
assert.equal(empty.searchParams.get('sha256'), guide.sha256);
|
|
});
|
|
|
|
await test('private saves use actual browser values and return to the same dossier without putting notes in URLs', async () => {
|
|
const h = harness(),
|
|
extra = { case: dossier, canWrite: true };
|
|
h.render(extra);
|
|
h.refs[0].current = {};
|
|
await withData(
|
|
{
|
|
'instruments[]': ['debt'],
|
|
note: 'SYNTHETIC autofilled private note',
|
|
},
|
|
async () => {
|
|
nodes(h.render(extra))
|
|
.find((n) => n.type === 'form')
|
|
.props.onSubmit({ preventDefault() {} });
|
|
await tick();
|
|
},
|
|
);
|
|
assert.equal(h.requests[0][1].note, 'SYNTHETIC autofilled private note');
|
|
assert.deepEqual(h.requests[0][1].instruments, ['debt']);
|
|
assert.equal(h.requests[0][1].sha256, guide.sha256);
|
|
assert.deepEqual(h.visits, [
|
|
[
|
|
'/investimentos/SYNTHETIC-case/guias/capitalizacao',
|
|
{ preserveState: false },
|
|
],
|
|
]);
|
|
});
|
|
|
|
await test('conflicts preserve private notes and require explicit comparison before adopting the current dossier version', async () => {
|
|
const h = harness(),
|
|
extra = { case: dossier, canWrite: true };
|
|
h.render(extra);
|
|
h.refs[0].current = {};
|
|
h.fail();
|
|
await withData(
|
|
{ 'instruments[]': ['debt'], note: 'SYNTHETIC unsaved note' },
|
|
async () => {
|
|
nodes(h.render(extra))
|
|
.find((n) => n.type === 'form')
|
|
.props.onSubmit({ preventDefault() {} });
|
|
await tick();
|
|
},
|
|
);
|
|
assert.equal(
|
|
byName(h.render(extra), 'note').props.value,
|
|
'SYNTHETIC unsaved note',
|
|
);
|
|
assert.equal(h.visits.length, 0);
|
|
await button(
|
|
h.render(extra),
|
|
'Conferir a referência actual sem perder a nota',
|
|
).props.onClick();
|
|
assert.equal(h.requests[1][2], 'GET');
|
|
assert.match(
|
|
renderToStaticMarkup(h.render(extra)),
|
|
/SYNTHETIC saved by coeditor/,
|
|
);
|
|
button(
|
|
h.render(extra),
|
|
'Conferi: manter a minha preparação numa nova revisão',
|
|
).props.onClick();
|
|
await withData({ 'instruments[]': ['debt'] }, async () => {
|
|
nodes(h.render(extra))
|
|
.find((n) => n.type === 'form')
|
|
.props.onSubmit({ preventDefault() {} });
|
|
await tick();
|
|
});
|
|
assert.equal(h.requests[2][1].version, 4);
|
|
assert.equal(h.requests[2][1].note, 'SYNTHETIC unsaved note');
|
|
h.withdraw();
|
|
await button(
|
|
h.render(extra),
|
|
'Conferir a referência actual sem perder a nota',
|
|
).props.onClick();
|
|
assert.equal(
|
|
button(
|
|
h.render(extra),
|
|
'Conferi: manter a minha preparação numa nova revisão',
|
|
),
|
|
undefined,
|
|
);
|
|
assert.equal(
|
|
byName(h.render(extra), 'note').props.value,
|
|
'SYNTHETIC unsaved note',
|
|
);
|
|
assert.match(
|
|
renderToStaticMarkup(h.render(extra)),
|
|
/guia utilizado foi alterado/,
|
|
);
|
|
});
|
|
|
|
await test('historical or withdrawn guides and consultation members cannot write or silently upgrade a saved edition', async () => {
|
|
for (const flags of [
|
|
{ historical: true },
|
|
{ olderEdition: true, currentAvailable: false },
|
|
{ canWrite: false },
|
|
]) {
|
|
const h = harness(),
|
|
tree = h.render({ case: dossier, canWrite: true, ...flags });
|
|
assert.equal(
|
|
nodes(tree).find((n) => n.type === 'fieldset').props.disabled,
|
|
true,
|
|
);
|
|
nodes(tree)
|
|
.find((n) => n.type === 'form')
|
|
.props.onSubmit({ preventDefault() {} });
|
|
await tick();
|
|
assert.equal(h.requests.length, 0);
|
|
if (flags.currentAvailable === false) {
|
|
const html = renderToStaticMarkup(tree);
|
|
assert.match(html, /retirado da consulta actual/);
|
|
assert.doesNotMatch(html, /\?current=1/);
|
|
}
|
|
}
|
|
});
|
|
|
|
await test('dossier chooser preserves the selected source edition on existing and newly created investment paths', () => {
|
|
let created;
|
|
const selection = {
|
|
slug: guide.slug,
|
|
sha256: guide.sha256,
|
|
instruments: ['debt'],
|
|
};
|
|
const module = load('../../resources/js/pages/investor-path/index.tsx', {
|
|
react: { useState: (fn) => [typeof fn === 'function' ? fn() : fn] },
|
|
'@inertiajs/react': {
|
|
Head: 'title',
|
|
Link: 'a',
|
|
router: {},
|
|
useForm: (data) => {
|
|
created = data;
|
|
return { data, errors: {}, processing: false };
|
|
},
|
|
},
|
|
'@/lib/investment-guides': lib,
|
|
'@/lib/operation-key': {
|
|
createOperationKey: () => 'SYNTHETIC-operation',
|
|
},
|
|
'@/lib/company-comparison': {},
|
|
});
|
|
const tree = module.default({
|
|
contexts: [{ ...dossier, updated_at: '2026-11-02' }],
|
|
guideSelection: selection,
|
|
});
|
|
assert.deepEqual(created.investment_guide, selection);
|
|
const link = nodes(tree).find(
|
|
(n) =>
|
|
typeof n.props?.href === 'string' &&
|
|
n.props.href.includes('/guias/'),
|
|
);
|
|
const url = new URL(link.props.href, 'https://portal.test');
|
|
assert.equal(
|
|
url.pathname,
|
|
'/investimentos/SYNTHETIC-case/guias/capitalizacao',
|
|
);
|
|
assert.equal(url.searchParams.get('sha256'), guide.sha256);
|
|
assert.match(
|
|
renderToStaticMarkup(tree),
|
|
/só serão guardados quando confirmar/,
|
|
);
|
|
});
|