Files
Investidor-V2/tests/frontend/opportunity-alert-preferences.test.mjs
T
2026-11-02 20:31:15 +02:00

455 lines
17 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 { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';
const require = createRequire(import.meta.url);
function load(path, mocks) {
const output = ts.transpileModule(
fs.readFileSync(new URL(path, 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 (!tree || typeof tree !== 'object') return [];
if (Array.isArray(tree)) return tree.flatMap(nodes);
return [tree, ...nodes(tree.props?.children)];
}
await test('preferences submit their own version and chosen criteria, with explicit opt-in and an independent cancellation action', () => {
let form, sent;
const Component = load(
'../../resources/js/components/opportunity-alert-preferences.tsx',
{
'@inertiajs/react': {
useForm(initial) {
form ??= {
data: initial,
errors: {},
processing: false,
setData(key, value) {
this.data = { ...this.data, [key]: value };
},
transform(fn) {
this.apply = fn;
},
put(url, options) {
sent = {
url,
options,
data: this.apply(this.data),
};
},
};
return form;
},
},
'@/components/opportunity-search-filters': {
modalityLabels: {
investment_project: 'Projecto de investimento',
},
},
'@/pages/investment/shared': { card: '', button: '', input: '' },
},
);
const preferences = {
version: 7,
enabled: true,
frequency: 'daily',
channel: 'in_app',
next_run_at: null,
criteria: {
sector: 'qa',
province_id: 'province',
category: 'investment_project',
},
};
const render = () =>
Component({
preferences,
options: {
frequencies: { daily: 'Daily' },
sectors: [{ slug: 'qa', name: 'Synthetic sector' }],
provinces: [{ id: 'province', name: 'Synthetic province' }],
categories: ['investment_project'],
},
});
let tree = render();
assert.equal(form.data.consent, false);
assert.ok(
renderToStaticMarkup(tree).includes('Não são enviados emails ou SMS'),
);
const criteria = nodes(tree).find(
(node) => node.type === 'select' && node.props.value === 'qa',
);
criteria.props.onChange({ target: { value: '' } });
tree = render();
assert.equal(form.data.criteria.province_id, 'province');
nodes(tree)
.find(
(node) =>
node.type === 'input' && node.props.name === 'alert-consent',
)
.props.onChange({ target: { checked: true } });
tree = render();
nodes(tree)
.find((node) => node.type === 'form')
.props.onSubmit({ preventDefault() {} });
assert.equal(sent.url, '/investidor/alert-preferences');
assert.equal(sent.data.version, 7);
assert.equal(sent.data.channel, 'in_app');
assert.equal(sent.data.enabled, true);
assert.equal(sent.data.consent, true);
assert.equal(sent.data.criteria.sector, '');
nodes(tree)
.find(
(node) =>
node.type === 'button' &&
node.props.children === 'Cancelar estes alertas',
)
.props.onClick();
assert.equal(sent.data.enabled, false);
assert.equal(sent.data.consent, false);
assert.equal(preferences.enabled, true);
const unavailable = renderToStaticMarkup(
Component({
preferences: { ...preferences, criteria_available: false },
options: {
frequencies: { daily: 'Daily' },
sectors: [],
provinces: [],
categories: [],
},
}),
);
assert.ok(
unavailable.includes(
'O acompanhamento das selecções indisponíveis fica suspenso',
),
);
assert.ok(unavailable.includes('Província anterior indisponível'));
assert.ok(unavailable.includes('Cancelar estes alertas'));
assert.equal(form.data.criteria.province_id, 'province');
});
await test('opportunity notifications preserve the legacy manifestation action and link to the public fiche', () => {
const Page = load('../../resources/js/pages/investment/notifications.tsx', {
'@inertiajs/react': { Link: 'a', router: { post() {} } },
'./shared': { Frame: 'main', card: '', button: '' },
'@/components/opportunity-alert-preferences': {
__esModule: true,
default: () => null,
},
});
const html = renderToStaticMarkup(
Page({
unread: 2,
notifications: {
data: [
{
id: 'alert',
opportunity_id: 12,
submission_id: null,
kind: 'opportunity_match',
payload: {
title: 'Synthetic public opportunity',
slug: 'synthetic-fiche',
source: 'Synthetic catalogue',
preference_version: 7,
},
created_at: '2026-11-02T12:00:00Z',
read_at: null,
},
{
id: 'legacy',
submission_id: 'synthetic-submission',
kind: 'received',
payload: { reference: 'SYNTHETIC-REF' },
created_at: '2026-11-02T12:00:00Z',
read_at: null,
},
],
prev_page_url: null,
next_page_url: null,
},
}),
);
assert.ok(html.includes('/oportunidades/synthetic-fiche'));
assert.ok(html.includes('/investidor/manifestacoes/synthetic-submission'));
assert.ok(html.includes('versão 7'));
assert.ok(html.includes('Manifestação recebida'));
assert.ok(!html.includes('/manifestacoes/null'));
});
await test('tender alerts require an explicit source choice and show only their applicable form controls', () => {
let form, sent;
const Component = load(
'../../resources/js/components/opportunity-alert-preferences.tsx',
{
'@inertiajs/react': {
useForm(initial) {
form ??= {
data: initial,
errors: {},
processing: false,
setData(key, value) {
this.data = { ...this.data, [key]: value };
},
transform(fn) {
this.apply = fn;
},
put(url) {
sent = { url, data: this.apply(this.data) };
},
};
return form;
},
},
'@/components/opportunity-search-filters': {
modalityLabels: {
investment_project: 'Projecto de investimento',
},
},
'@/pages/investment/shared': { card: '', button: '', input: '' },
},
);
const preferences = {
version: 3,
enabled: true,
frequency: 'daily',
channel: 'in_app',
next_run_at: null,
criteria: { category: 'investment_project' },
};
const options = {
frequencies: { daily: 'Daily' },
sectors: [],
provinces: [],
categories: ['investment_project'],
tendersAvailable: true,
tenderRegimes: { private: 'Concurso privado' },
};
const render = (currentOptions = options) =>
Component({ preferences, options: currentOptions });
let tree = render();
assert.deepEqual(form.data.sources, ['opportunities']);
assert.equal(
nodes(tree).find((node) => node.props?.name === 'source-tenders').props
.checked,
false,
);
form.setData('consent', true);
nodes(tree)
.find((node) => node.props?.name === 'source-tenders')
.props.onChange({ target: { checked: true } });
assert.equal(form.data.consent, false);
tree = render();
nodes(tree)
.find((node) => node.props?.name === 'source-opportunities')
.props.onChange({ target: { checked: false } });
tree = render();
const html = renderToStaticMarkup(tree);
assert.ok(html.includes('Regime do concurso'));
assert.ok(!html.includes('Todas as modalidades'));
assert.ok(html.includes('Concurso privado'));
form.setData('consent', true);
nodes(tree)
.find((node) => node.type === 'form')
.props.onSubmit({ preventDefault() {} });
assert.deepEqual(sent.data.sources, ['tenders']);
tree = render({ ...options, tendersAvailable: false });
const unavailable = nodes(tree).find(
(node) => node.props?.name === 'source-tenders',
);
assert.equal(
unavailable.props.disabled,
false,
'An unavailable selected source must remain removable.',
);
unavailable.props.onChange({ target: { checked: false } });
assert.deepEqual(form.data.sources, []);
});
await test('published tender acts retain their identity and use the tender action rather than a manifestation URL', () => {
const Page = load('../../resources/js/pages/investment/notifications.tsx', {
'@inertiajs/react': { Link: 'a', router: { post() {} } },
'./shared': { Frame: 'main', card: '', button: '' },
'@/components/opportunity-alert-preferences': {
__esModule: true,
default: () => null,
},
});
const html = renderToStaticMarkup(
Page({
unread: 1,
alertOptions: { tenderActs: { extension: 'Prorrogação' } },
notifications: {
data: [
{
id: 'synthetic-alert',
submission_id: null,
tender_version_id: 'synthetic-version',
kind: 'tender_match',
payload: {
title: 'Synthetic public tender',
tender_id: 'synthetic-tender',
act: 'extension',
publication_version: 4,
source: 'Synthetic catalogue',
preference_version: 3,
},
created_at: '2026-11-02T12:00:00Z',
read_at: null,
},
],
prev_page_url: null,
next_page_url: null,
},
}),
);
assert.ok(html.includes('/concursos/synthetic-tender'));
assert.ok(html.includes('Consultar concurso'));
assert.ok(html.includes('Prorrogação'));
assert.ok(html.includes('versão 4'));
assert.ok(html.includes('não apresenta uma proposta'));
assert.ok(!html.includes('/manifestacoes/null'));
});
await test('funding preferences require opt-in and expose scope and window choices only for that source', () => {
let form;
const Component = load(
'../../resources/js/components/opportunity-alert-preferences.tsx',
{
'@inertiajs/react': {
useForm(initial) {
form ??= {
data: initial,
processing: false,
errors: {},
setData(key, value) {
this.data = { ...this.data, [key]: value };
},
};
return form;
},
},
'@/components/opportunity-search-filters': { modalityLabels: {} },
'@/pages/investment/shared': { card: '', button: '', input: '' },
},
);
const preferences = {
version: 1,
enabled: true,
frequency: 'daily',
criteria: {},
channel: 'in_app',
next_run_at: null,
};
const options = {
frequencies: { daily: 'Daily' },
sectors: [],
provinces: [],
categories: [],
fundingAvailable: true,
};
let tree = Component({ preferences, options });
assert.deepEqual(form.data.sources, ['opportunities']);
assert.equal(
renderToStaticMarkup(tree).includes('Estado de uma janela'),
false,
);
form.setData('consent', true);
nodes(tree)
.find((node) => node.props?.name === 'source-funding')
.props.onChange({ target: { checked: true } });
assert.equal(form.data.consent, false);
tree = Component({ preferences, options });
assert.ok(
renderToStaticMarkup(tree).includes(
'Incluir e assinalar informação por confirmar',
),
);
assert.ok(renderToStaticMarkup(tree).includes('Estado de uma janela'));
assert.equal(form.data.criteria.funding_certainty, 'documented');
const unavailable = nodes(
Component({
preferences,
options: { ...options, fundingAvailable: false },
}),
).find((node) => node.props?.name === 'source-funding');
assert.equal(unavailable.props.disabled, false);
unavailable.props.onChange({ target: { checked: false } });
assert.deepEqual(form.data.sources, ['opportunities']);
});
await test('funding notice links to its programme and qualifies historical window states', () => {
const Page = load('../../resources/js/pages/investment/notifications.tsx', {
'@inertiajs/react': { Link: 'a', router: { post() {} } },
'./shared': { Frame: 'main', card: '', button: '' },
'@/components/opportunity-alert-preferences': {
__esModule: true,
default: () => null,
},
});
const html = renderToStaticMarkup(
Page({
unread: 1,
notifications: {
data: [
{
id: 'qa',
submission_id: null,
kind: 'funding_match',
payload: {
title: 'Synthetic fund',
programme_id: 'qa-fund',
source_url:
'https://example.invalid/funding-source',
source_reference: 'Synthetic source edition',
source_checked_on: '2026-11-02',
publication_version: 2,
scope_explanation: {
sector: 'Âmbito por confirmar',
},
window_states: [
{
title: 'Synthetic historical window',
state: 'closed',
end_on: '2025-10-20',
},
],
},
created_at: '2026-11-02T12:00:00Z',
read_at: null,
},
],
prev_page_url: null,
next_page_url: null,
},
}),
);
assert.ok(html.includes('/fundos/qa-fund'));
assert.ok(html.includes('https://example.invalid/funding-source'));
assert.ok(html.includes('Synthetic source edition'));
assert.ok(html.includes('não confirma uma candidatura aberta'));
assert.ok(html.includes('estado na data do aviso'));
assert.ok(html.includes('encerrada'));
assert.ok(html.includes('Âmbito por confirmar'));
assert.ok(!html.includes('/manifestacoes/null'));
});