621 lines
18 KiB
JavaScript
621 lines
18 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import test from 'node:test';
|
|
import {
|
|
isApiErrorV1,
|
|
isApiQuestionnaireReadResponseV1,
|
|
isApiSimulationJobsReadResponseV1,
|
|
isApiSimulationReportReadResponseV1,
|
|
isApiSimulationDraftAttachResponseV1,
|
|
isApiSimulationDraftReadResponseV1,
|
|
isApiSimulationDraftWriteResponseV1,
|
|
isApiSimulationHistoryResponseV1,
|
|
fiscalComponentCodes,
|
|
indeterminateFiscalComponent,
|
|
isApiSimulationCreateResponseV1,
|
|
isApiSimulationReadResponseV1,
|
|
isApiSimulationResultSnapshotV1,
|
|
isSimulationCurrency,
|
|
simulationCurrencyFractionDigits,
|
|
simulationCurrencyOptions,
|
|
validateSimulationResult,
|
|
} from '../../resources/js/lib/simulation-contract.ts';
|
|
|
|
const apiSnapshot = {
|
|
contract_version: 1,
|
|
context: {
|
|
reference_date: '2026-10-03',
|
|
currency: null,
|
|
currency_fraction_digits: null,
|
|
scenario_id: '87654321-aaaa-bbbb-cccc-123456789abc',
|
|
scenario_version: 1,
|
|
engine_version: 'portal-arithmetic-v1',
|
|
ruleset_version: null,
|
|
},
|
|
result: {
|
|
status: 'calculated',
|
|
value: '0.000000',
|
|
unit: null,
|
|
explanation: 'Arithmetic indicative.',
|
|
missing_inputs: [],
|
|
source_ids: [],
|
|
},
|
|
indicative_total: '0.000000',
|
|
legal_interpretation: false,
|
|
formula_version: 'portal-arithmetic-v1',
|
|
};
|
|
const apiExecution = {
|
|
id: '87654321-aaaa-bbbb-cccc-123456789abc',
|
|
status: 'queued',
|
|
version_no: 1,
|
|
result_snapshot: null,
|
|
};
|
|
const apiDraft = {
|
|
id: '87654321-aaaa-bbbb-cccc-123456789abc',
|
|
version_no: 2,
|
|
snapshot_sha256: 'a'.repeat(64),
|
|
personal_snapshot: [],
|
|
economic_snapshot: { purpose: 'simulate_only' },
|
|
simulation_sources: [],
|
|
updated_at: '2026-10-03T12:30:00.000000Z',
|
|
};
|
|
const apiHistory = {
|
|
data: [
|
|
{
|
|
id: apiExecution.id,
|
|
scope: 'personal',
|
|
organization_name: null,
|
|
status: 'completed',
|
|
version_no: 1,
|
|
created_at: '2026-10-03T12:00:00.000000Z',
|
|
updated_at: '2026-10-03T12:00:01.000000Z',
|
|
has_ready_report: true,
|
|
},
|
|
],
|
|
meta: { current_page: 1, last_page: 1, per_page: 10, total: 1 },
|
|
};
|
|
const apiJob = {
|
|
id: '87654321-aaaa-bbbb-cccc-123456789abc',
|
|
simulation_id: '87654321-aaaa-bbbb-cccc-123456789abc',
|
|
job_type: 'execute',
|
|
status: 'succeeded',
|
|
attempts: 1,
|
|
available_at: '2026-10-03T12:00:00.000000Z',
|
|
started_at: '2026-10-03T12:00:01.000000Z',
|
|
finished_at: '2026-10-03T12:00:02.000000Z',
|
|
error_code: null,
|
|
};
|
|
|
|
void test('OpenAPI matches decimal-string inputs and execution response guards', async () => {
|
|
const openapi = JSON.parse(
|
|
await readFile(
|
|
new URL(
|
|
'../../docs/investmoz/simulations/openapi.json',
|
|
import.meta.url,
|
|
),
|
|
'utf8',
|
|
),
|
|
);
|
|
const schemas = openapi.components.schemas;
|
|
|
|
assert.ok(schemas.DecimalInputString.pattern.includes(String.raw`\.`));
|
|
assert.equal(
|
|
schemas.SimulationInput.properties.base.allOf[0].$ref,
|
|
'#/components/schemas/DecimalInputString',
|
|
);
|
|
assert.equal(
|
|
schemas.SimulationInput.properties.rate.allOf[0].$ref,
|
|
'#/components/schemas/PercentageInputString',
|
|
);
|
|
assert.equal(
|
|
schemas.UpdateScenarioInput.properties.base.allOf[0].$ref,
|
|
'#/components/schemas/DecimalInputString',
|
|
);
|
|
assert.deepEqual(schemas.SimulationExecutionV1.properties.status.enum, [
|
|
'queued',
|
|
'running',
|
|
'completed',
|
|
'failed',
|
|
'cancelled',
|
|
]);
|
|
assert.equal(
|
|
openapi.paths['/simulations'].post.responses['202'].content[
|
|
'application/json'
|
|
].schema.$ref,
|
|
'#/components/schemas/SimulationCreateResponseV1',
|
|
);
|
|
assert.equal(
|
|
openapi.paths['/simulations/{id}'].get.responses['200'].content[
|
|
'application/json'
|
|
].schema.$ref,
|
|
'#/components/schemas/SimulationReadResponseV1',
|
|
);
|
|
assert.equal(
|
|
openapi.paths['/simulations/history'].get.responses['200'].content[
|
|
'application/json'
|
|
].schema.$ref,
|
|
'#/components/schemas/SimulationHistoryResponseV1',
|
|
);
|
|
assert.equal(
|
|
openapi.paths['/simulation-drafts/current'].get.responses['200']
|
|
.content['application/json'].schema.$ref,
|
|
'#/components/schemas/SimulationDraftReadResponseV1',
|
|
);
|
|
assert.equal(
|
|
openapi.paths['/questionnaires/{code}'].get.responses['200'].content[
|
|
'application/json'
|
|
].schema.$ref,
|
|
'#/components/schemas/QuestionnaireReadResponseV1',
|
|
);
|
|
assert.equal(
|
|
openapi.paths['/simulations/{id}/jobs'].get.responses['200'].content[
|
|
'application/json'
|
|
].schema.$ref,
|
|
'#/components/schemas/SimulationJobsReadResponseV1',
|
|
);
|
|
assert.equal(
|
|
openapi.paths['/simulations/{id}/scenarios/{scenarioId}'].patch
|
|
.responses['202'].content['application/json'].schema.$ref,
|
|
'#/components/schemas/SimulationReadResponseV1',
|
|
);
|
|
assert.equal(
|
|
openapi.paths['/simulations/{id}/scenarios/{scenarioId}'].patch
|
|
.responses['422'].content['application/json'].schema.$ref,
|
|
'#/components/schemas/ApiErrorV1',
|
|
);
|
|
assert.equal(
|
|
schemas.SimulationHistoryResponseV1.properties.meta.properties.per_page
|
|
.maximum,
|
|
50,
|
|
);
|
|
for (const pathItem of Object.values(openapi.paths)) {
|
|
for (const operation of Object.values(pathItem)) {
|
|
for (const [status, response] of Object.entries(
|
|
operation.responses ?? {},
|
|
)) {
|
|
if (!/^4\d\d$/.test(status)) continue;
|
|
assert.equal(
|
|
response.content?.['application/json']?.schema?.$ref,
|
|
'#/components/schemas/ApiErrorV1',
|
|
`HTTP ${status} must use the shared API error contract`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('currency codes are explicit and must be present in the supported currency catalog', () => {
|
|
assert.equal(isSimulationCurrency('MZN'), true);
|
|
assert.equal(isSimulationCurrency('USD'), true);
|
|
assert.equal(isSimulationCurrency('mzn'), false);
|
|
assert.equal(isSimulationCurrency('XYZ'), false);
|
|
assert.equal(simulationCurrencyFractionDigits('JPY'), 0);
|
|
assert.equal(simulationCurrencyFractionDigits('KWD'), 3);
|
|
assert.equal(simulationCurrencyOptions.includes('JPY'), false);
|
|
assert.equal(simulationCurrencyOptions.includes('KWD'), false);
|
|
});
|
|
|
|
void test('calculated values preserve exact decimal strings, including zero', () => {
|
|
assert.deepEqual(
|
|
validateSimulationResult({
|
|
status: 'calculated',
|
|
value: '0.00',
|
|
unit: 'MZN',
|
|
explanation: 'Cenário aritmético.',
|
|
missingInputs: [],
|
|
sourceIds: [],
|
|
}),
|
|
[],
|
|
);
|
|
});
|
|
|
|
void test('API v1 wire envelope preserves exact zero and validates indeterminate states', () => {
|
|
assert.equal(isApiSimulationResultSnapshotV1(apiSnapshot), true);
|
|
assert.equal(
|
|
isApiSimulationResultSnapshotV1({
|
|
...apiSnapshot,
|
|
context: { ...apiSnapshot.context, reference_date: '2026-02-31' },
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationResultSnapshotV1({
|
|
...apiSnapshot,
|
|
context: { ...apiSnapshot.context, reference_date: '2024-02-29' },
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationResultSnapshotV1({
|
|
...apiSnapshot,
|
|
indicative_total: null,
|
|
result: {
|
|
...apiSnapshot.result,
|
|
status: 'indeterminate',
|
|
value: null,
|
|
missing_inputs: ['approved_rule'],
|
|
},
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationResultSnapshotV1({
|
|
...apiSnapshot,
|
|
indicative_total: '0.000001',
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationResultSnapshotV1({
|
|
...apiSnapshot,
|
|
result: { ...apiSnapshot.result, value: '0' },
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationResultSnapshotV1({
|
|
...apiSnapshot,
|
|
result: {
|
|
...apiSnapshot.result,
|
|
status: 'indeterminate',
|
|
missing_inputs: [],
|
|
},
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
void test('simulation creation and polling responses require a valid execution view', () => {
|
|
const queuedExecution = { ...apiExecution };
|
|
delete queuedExecution.result_snapshot;
|
|
assert.equal(
|
|
isApiSimulationCreateResponseV1({
|
|
data: queuedExecution,
|
|
replayed: false,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(isApiSimulationReadResponseV1({ data: apiExecution }), true);
|
|
const completedExecution = {
|
|
...apiExecution,
|
|
status: 'completed',
|
|
version_no: 2,
|
|
result_snapshot: apiSnapshot,
|
|
};
|
|
assert.equal(
|
|
isApiSimulationReadResponseV1({ data: completedExecution }),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationReadResponseV1({
|
|
data: { ...completedExecution, result_snapshot: { broken: true } },
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationReadResponseV1({
|
|
data: { ...completedExecution, result_snapshot: null },
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationReadResponseV1({
|
|
data: {
|
|
...apiExecution,
|
|
result_snapshot: {
|
|
contract_version: 1,
|
|
indicative_total: '12',
|
|
},
|
|
},
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationReadResponseV1({
|
|
data: {
|
|
...completedExecution,
|
|
result_snapshot: { indicative_total: '12.50' },
|
|
},
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationCreateResponseV1({
|
|
data: apiExecution,
|
|
replayed: false,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationCreateResponseV1({
|
|
data: apiExecution,
|
|
replayed: true,
|
|
access_token: 'x'.repeat(48),
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationReadResponseV1({
|
|
data: { ...apiExecution, status: 'unknown' },
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationReadResponseV1({
|
|
data: { id: apiExecution.id, status: 'completed', version_no: 2 },
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationCreateResponseV1({
|
|
data: apiExecution,
|
|
replayed: 'false',
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
void test('history and encrypted-draft endpoints reject malformed envelopes', () => {
|
|
assert.equal(isApiSimulationHistoryResponseV1(apiHistory), true);
|
|
assert.equal(
|
|
isApiSimulationHistoryResponseV1({
|
|
...apiHistory,
|
|
data: [{ ...apiHistory.data[0], status: 'unrecognized' }],
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationHistoryResponseV1({ ...apiHistory, meta: {} }),
|
|
false,
|
|
);
|
|
|
|
assert.equal(isApiSimulationDraftReadResponseV1({ data: apiDraft }), true);
|
|
assert.equal(isApiSimulationDraftReadResponseV1({ data: null }), true);
|
|
assert.equal(
|
|
isApiSimulationDraftReadResponseV1({
|
|
data: { ...apiDraft, personal_snapshot: ['private'] },
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationDraftWriteResponseV1({
|
|
data: { id: apiDraft.id, version_no: 3 },
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationDraftWriteResponseV1({
|
|
data: { id: 'bad', version_no: 0 },
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationDraftAttachResponseV1({
|
|
data: apiDraft,
|
|
replayed: false,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationDraftAttachResponseV1({
|
|
data: apiDraft,
|
|
replayed: 'false',
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
void test('questionnaire, report, job and error envelopes match their server contracts', () => {
|
|
assert.equal(
|
|
isApiQuestionnaireReadResponseV1({
|
|
data: {
|
|
id: apiExecution.id,
|
|
code: 'user-entered-arithmetic',
|
|
version_no: 1,
|
|
locale: 'pt-MZ',
|
|
schema_snapshot: { inputs: {} },
|
|
status: 'published',
|
|
valid_from: '2026-10-01',
|
|
valid_to: null,
|
|
},
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isApiQuestionnaireReadResponseV1({
|
|
data: {
|
|
id: apiExecution.id,
|
|
code: 'user-entered-arithmetic',
|
|
version_no: 1,
|
|
locale: 'pt-MZ',
|
|
schema_snapshot: { inputs: {} },
|
|
status: 'published',
|
|
valid_from: '2026-02-31',
|
|
valid_to: null,
|
|
},
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiQuestionnaireReadResponseV1({ data: { locale: 'en' } }),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationReportReadResponseV1({
|
|
data: {
|
|
id: apiExecution.id,
|
|
simulation_id: apiExecution.id,
|
|
version_no: 1,
|
|
status: 'ready',
|
|
report_snapshot: apiSnapshot,
|
|
report_sha256: 'b'.repeat(64),
|
|
},
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationReportReadResponseV1({
|
|
data: {
|
|
id: apiExecution.id,
|
|
simulation_id: apiExecution.id,
|
|
version_no: 1,
|
|
status: 'ready',
|
|
report_snapshot: [],
|
|
report_sha256: 'invalid',
|
|
},
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationReportReadResponseV1({
|
|
data: {
|
|
id: apiExecution.id,
|
|
simulation_id: apiExecution.id,
|
|
version_no: 1,
|
|
status: 'queued',
|
|
report_snapshot: apiSnapshot,
|
|
report_sha256: 'b'.repeat(64),
|
|
},
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiSimulationReportReadResponseV1({
|
|
data: {
|
|
id: apiExecution.id,
|
|
simulation_id: apiExecution.id,
|
|
version_no: 1,
|
|
status: 'ready',
|
|
report_snapshot: null,
|
|
report_sha256: null,
|
|
},
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(isApiSimulationJobsReadResponseV1({ data: [apiJob] }), true);
|
|
assert.equal(
|
|
isApiSimulationJobsReadResponseV1({
|
|
data: [{ ...apiJob, attempts: 4 }],
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isApiErrorV1({
|
|
message: 'Validation failed',
|
|
errors: { base: ['Invalid'] },
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(isApiErrorV1({ message: 42 }), false);
|
|
assert.equal(isApiErrorV1({ errors: { base: 'Invalid' } }), false);
|
|
});
|
|
|
|
void test('wire contract guards survive a real JSON encode/decode round trip', () => {
|
|
const wirePayloads = [
|
|
[isApiSimulationReadResponseV1, { data: apiExecution }],
|
|
[
|
|
isApiSimulationCreateResponseV1,
|
|
{
|
|
data: apiExecution,
|
|
replayed: false,
|
|
access_token: 'x'.repeat(48),
|
|
},
|
|
],
|
|
[isApiSimulationHistoryResponseV1, apiHistory],
|
|
[isApiSimulationDraftReadResponseV1, { data: apiDraft }],
|
|
[
|
|
isApiSimulationDraftAttachResponseV1,
|
|
{ data: apiDraft, replayed: false },
|
|
],
|
|
[isApiSimulationJobsReadResponseV1, { data: [apiJob] }],
|
|
[
|
|
isApiSimulationResultSnapshotV1,
|
|
{ ...apiSnapshot, result: { ...apiSnapshot.result } },
|
|
],
|
|
];
|
|
|
|
for (const [guard, payload] of wirePayloads) {
|
|
const decoded = JSON.parse(JSON.stringify(payload));
|
|
assert.equal(guard(decoded), true);
|
|
}
|
|
});
|
|
|
|
void test('indeterminate results require missing inputs and cannot carry a numeric value', () => {
|
|
assert.deepEqual(
|
|
validateSimulationResult({
|
|
status: 'indeterminate',
|
|
value: null,
|
|
unit: null,
|
|
explanation: 'Falta a classificação.',
|
|
missingInputs: ['tariff_code'],
|
|
sourceIds: [],
|
|
}),
|
|
[],
|
|
);
|
|
assert.deepEqual(
|
|
validateSimulationResult({
|
|
status: 'indeterminate',
|
|
value: '0',
|
|
unit: 'MZN',
|
|
explanation: 'Sem dados.',
|
|
missingInputs: [],
|
|
sourceIds: [],
|
|
}),
|
|
['indeterminate_inputs'],
|
|
);
|
|
});
|
|
|
|
void test('estimated and not-applicable states must be explicit', () => {
|
|
assert.deepEqual(
|
|
validateSimulationResult({
|
|
status: 'estimated',
|
|
value: '12.30',
|
|
unit: 'MZN',
|
|
explanation: 'Estimativa incompleta.',
|
|
missingInputs: ['tariff_code'],
|
|
sourceIds: [],
|
|
}),
|
|
[],
|
|
);
|
|
assert.deepEqual(
|
|
validateSimulationResult({
|
|
status: 'not_applicable',
|
|
value: null,
|
|
unit: null,
|
|
explanation: 'Fora do cenário.',
|
|
missingInputs: [],
|
|
sourceIds: [],
|
|
}),
|
|
[],
|
|
);
|
|
assert.deepEqual(
|
|
validateSimulationResult({
|
|
status: 'not_applicable',
|
|
value: '0',
|
|
unit: 'MZN',
|
|
explanation: 'Fora do cenário.',
|
|
missingInputs: [],
|
|
sourceIds: [],
|
|
}),
|
|
['not_applicable_value'],
|
|
);
|
|
});
|
|
|
|
void test('every tax component remains indeterminate until an approved effective rule exists', () => {
|
|
assert.deepEqual(fiscalComponentCodes, [
|
|
'IRPS',
|
|
'INSS',
|
|
'IVA',
|
|
'IRPC',
|
|
'ISPC',
|
|
'RETENCOES',
|
|
]);
|
|
for (const code of fiscalComponentCodes) {
|
|
const result = indeterminateFiscalComponent(code, '2026-10-02');
|
|
assert.deepEqual(validateSimulationResult(result), []);
|
|
assert.equal(result.value, null);
|
|
assert.ok(result.missingInputs.includes('approved_rule'));
|
|
assert.deepEqual(result.sourceIds, []);
|
|
}
|
|
});
|