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')); });