From 26f6a0d348e9f234759f9bbf17d888f81c33173c Mon Sep 17 00:00:00 2001 From: nala Date: Thu, 29 Oct 2026 23:01:36 +0200 Subject: [PATCH] fix: reject stale context previews and protect pending edits --- .../consolidated-review-matrix-2026-10-03.md | 11 + .../local-validation-20261029.json | 40 ++++ .../unification/traceability-and-manual.md | 11 + resources/js/pages/investor-path/context.tsx | 11 + .../investor-usability-components.test.mjs | 192 ++++++++++++++++++ 5 files changed, 265 insertions(+) diff --git a/docs/investmoz/consolidated-review-matrix-2026-10-03.md b/docs/investmoz/consolidated-review-matrix-2026-10-03.md index c04f0e00..8c078ceb 100644 --- a/docs/investmoz/consolidated-review-matrix-2026-10-03.md +++ b/docs/investmoz/consolidated-review-matrix-2026-10-03.md @@ -2141,3 +2141,14 @@ Corrigido no componente existente: comparação por estado e identificador docum Dois novos casos falharam contra o código anterior e passaram na candidata. Dez testes frontend dirigidos passaram, cobrindo também troca com o mesmo nome, retirada, nova associação, nome indisponível, histórico sem aplicação, ausência de alterações e preservação da origem. Tipos, lint, formatação e compilação QA passaram. Provas históricas aceites reutilizadas, sem repetição integral. Recibo e hashes no campo `adaptive_document_impact_increment` de `local-validation-20261029.json`. **Visual pendente pela equipa:** na candidata e num dossier autorizado, preparar troca/retirada de documento, comparar antes de confirmar e conferir nomes/estado anterior e posterior; confirmar que associação mantida não surge como substituição e que o histórico permanece. Não é necessário confirmar uma alteração real para observar a prévia. Registar revisão, percurso, perfil, esperado/observado e evidência nesta matriz. Nenhuma instalação ou avanço de main; o incremento não encerra o percurso adaptativo transversal. + + +### 29/10/2026 — comparação de contexto: proteger edições e recusar prévias desactualizadas + +Continuação sobre `a10dd83d`, nos requisitos existentes V2-07/V2-10 e na orientação adaptativa da Tempo de 23/10. Alguns controlos de declarações, adição de oportunidade e substituição explícita continuavam editáveis durante o pedido de comparação. A resposta podia tornar confirmável um plano anterior às escolhas apresentadas; iniciar outra comparação também conservava o plano precedente em caso de falha. + +Corrigido no editor existente: os controlos referidos acompanham o estado ocupado dos restantes; uma nova comparação invalida o plano e corpo de confirmação anteriores; antes de aceitar a resposta compara-se o snapshot do rascunho pedido com o actual. Se diferirem, a resposta não pode ser confirmada e as edições posteriores ficam conservadas, com pedido explícito para comparar de novo. É consistência técnica da confirmação existente, sem nova condição organizacional e sem alteração de backend, schema, permissões ou cálculo. + +Três casos novos falharam contra a revisão anterior e passaram na candidata: bloqueio/recuperação dos controlos durante comparação, recusa de resposta para rascunho alterado preservando a edição, e falha de nova comparação sem reutilização do plano anterior. Treze testes frontend dirigidos passaram. Tipos, lint, formatação e compilação QA passaram; provas históricas aceites reutilizadas, sem repetição integral. Detalhes/hashes em `context_preview_consistency_increment` do recibo existente `local-validation-20261029.json`. + +**Conferência visual pendente pela equipa:** na candidata, comparar dados de um dossier autorizado, conferir bloqueio temporário e reposição dos controlos; preparar nova edição e repetir a comparação, garantindo que só a prévia actual permite guardar. Em falha de comunicação, os valores permanecem e exige-se nova comparação. Não gravar dados sintéticos no activo para este ensaio. Registar revisão, perfil, percurso, esperado/observado e evidência nesta matriz. Sem instalação ou avanço de main; os requisitos transversais continuam parciais. diff --git a/docs/investmoz/unification/local-validation-20261029.json b/docs/investmoz/unification/local-validation-20261029.json index 881e009d..2f3e14c4 100644 --- a/docs/investmoz/unification/local-validation-20261029.json +++ b/docs/investmoz/unification/local-validation-20261029.json @@ -1290,5 +1290,45 @@ "preservation": "Existing before/after backend preview and confirmation flow retained; no data writes, schema, permissions, tax rules or backend changes. Historical accepted suites reused.", "visual_acceptance": "Pending team execution and recording against candidate.", "installation": "Not triggered; main and active release unchanged." + }, + "context_preview_consistency_increment": { + "recorded_at": "2026-10-29T21:01:36.362775+00:00", + "base_commit": "a10dd83d8dda0eab50d1c77ef3920ba9d5daf172", + "requirements": [ + "V2-07", + "V2-10", + "Tempo adaptive journey decision 2026-10-23: preserve deliberate edits and compare impact before confirmation" + ], + "behavior": [ + "Opportunity addition, common declarations and explicit replacement checkboxes are disabled while a context operation is busy, consistently with existing preparation controls.", + "Starting a new preview clears the preceding plan and confirmation body, so a failed new preview cannot leave an older preview confirmable.", + "Before accepting the asynchronous response, compare the current draft snapshot to the requested draft; reject stale responses and preserve later edits with an explicit request to compare again." + ], + "checks": { + "new_cases_reproduced_against_previous_revision": 3, + "directed_frontend_passed": 13, + "new_frontend_cases": 3, + "existing_cases_reused": 10, + "types": "passed", + "lint": "passed", + "format": "passed", + "qa_build": "passed" + }, + "source_sha256": { + "resources/js/pages/investor-path/context.tsx": "41c919e19246d0e913603cbbb747b82d7ed41f0d93caaadd072443704891eb0d", + "tests/frontend/investor-usability-components.test.mjs": "21b23496b8bb009cc52e13e0061e32c18338d8a325eb74c8fbf5e865de8db99f" + }, + "evidence": { + "outputs/validation-20261029/context-preview-red.log": "6524233d96ce46217274fd174d2764b4c9b9d2289cbdfd0b48413913fcebded8", + "outputs/validation-20261029/context-preview-tests.log": "584f31229dca89319d25fe6f981813bed010b621bb28165f11fbc9f9f44b5915", + "outputs/validation-20261029/context-preview-types.log": "82962651e47f564de653ad2883f8fd34e7dae4b4468e4ac459cb3331962a3879", + "outputs/validation-20261029/context-preview-lint.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "outputs/validation-20261029/context-preview-format-check.log": "90f26a8036b540babd912c8093386e9f09e41d13b5debbd3ed1a5c6e4bcbd3c6", + "outputs/validation-20261029/context-preview-build.log": "4aafb7a9bf6062810cdbbcd000a49bea2043c89540af876ae1b5d77f397c0ad6", + "outputs/validation-20261029/context-preview-build-exit.json": "124fd50cb6d223cdd18fedc166d00af7254199b6e6dedf9be82f71db87af6d01" + }, + "preservation": "Frontend request consistency only. No new business conditions, backend/schema/permissions/tax-rule changes or writes to active data. Existing accepted suites reused without full rerun.", + "visual_acceptance": "Pending team execution and recording against candidate; mocked asynchronous component tests are not a browser-session acceptance.", + "installation": "Not triggered; main and active release unchanged." } } diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 3cb36f4c..1bd3304a 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -7678,3 +7678,14 @@ Corrigido no componente existente: comparação por estado e identificador docum Dois novos casos falharam contra o código anterior e passaram na candidata. Dez testes frontend dirigidos passaram, cobrindo também troca com o mesmo nome, retirada, nova associação, nome indisponível, histórico sem aplicação, ausência de alterações e preservação da origem. Tipos, lint, formatação e compilação QA passaram. Provas históricas aceites reutilizadas, sem repetição integral. Recibo e hashes no campo `adaptive_document_impact_increment` de `local-validation-20261029.json`. **Visual pendente pela equipa:** na candidata e num dossier autorizado, preparar troca/retirada de documento, comparar antes de confirmar e conferir nomes/estado anterior e posterior; confirmar que associação mantida não surge como substituição e que o histórico permanece. Não é necessário confirmar uma alteração real para observar a prévia. Registar revisão, percurso, perfil, esperado/observado e evidência nesta matriz. Nenhuma instalação ou avanço de main; o incremento não encerra o percurso adaptativo transversal. + + +### 29/10/2026 — comparação de contexto: proteger edições e recusar prévias desactualizadas + +Continuação sobre `a10dd83d`, nos requisitos existentes V2-07/V2-10 e na orientação adaptativa da Tempo de 23/10. Alguns controlos de declarações, adição de oportunidade e substituição explícita continuavam editáveis durante o pedido de comparação. A resposta podia tornar confirmável um plano anterior às escolhas apresentadas; iniciar outra comparação também conservava o plano precedente em caso de falha. + +Corrigido no editor existente: os controlos referidos acompanham o estado ocupado dos restantes; uma nova comparação invalida o plano e corpo de confirmação anteriores; antes de aceitar a resposta compara-se o snapshot do rascunho pedido com o actual. Se diferirem, a resposta não pode ser confirmada e as edições posteriores ficam conservadas, com pedido explícito para comparar de novo. É consistência técnica da confirmação existente, sem nova condição organizacional e sem alteração de backend, schema, permissões ou cálculo. + +Três casos novos falharam contra a revisão anterior e passaram na candidata: bloqueio/recuperação dos controlos durante comparação, recusa de resposta para rascunho alterado preservando a edição, e falha de nova comparação sem reutilização do plano anterior. Treze testes frontend dirigidos passaram. Tipos, lint, formatação e compilação QA passaram; provas históricas aceites reutilizadas, sem repetição integral. Detalhes/hashes em `context_preview_consistency_increment` do recibo existente `local-validation-20261029.json`. + +**Conferência visual pendente pela equipa:** na candidata, comparar dados de um dossier autorizado, conferir bloqueio temporário e reposição dos controlos; preparar nova edição e repetir a comparação, garantindo que só a prévia actual permite guardar. Em falha de comunicação, os valores permanecem e exige-se nova comparação. Não gravar dados sintéticos no activo para este ensaio. Registar revisão, perfil, percurso, esperado/observado e evidência nesta matriz. Sem instalação ou avanço de main; os requisitos transversais continuam parciais. diff --git a/resources/js/pages/investor-path/context.tsx b/resources/js/pages/investor-path/context.tsx index f8c1a255..1174baaa 100644 --- a/resources/js/pages/investor-path/context.tsx +++ b/resources/js/pages/investor-path/context.tsx @@ -372,6 +372,8 @@ export default function ContextEditor({ }; async function preview() { setBusy(true); + setPlan(null); + setRequestBody(null); setError(''); setMessage(''); const body = { @@ -403,6 +405,11 @@ export default function ContextEditor({ `/investimentos/${view.case.id}/contexto/previa`, body, ); + if (latestDraft.current !== draftBody) { + throw new Error( + 'Os dados mudaram durante a comparação. Reveja novamente antes de guardar.', + ); + } setRequestBody(body); setPlan(result); setPreparation( @@ -656,6 +663,7 @@ export default function ContextEditor({ { setReplace( diff --git a/tests/frontend/investor-usability-components.test.mjs b/tests/frontend/investor-usability-components.test.mjs index ca3afe54..057580f6 100644 --- a/tests/frontend/investor-usability-components.test.mjs +++ b/tests/frontend/investor-usability-components.test.mjs @@ -476,6 +476,198 @@ const preparationMocks = { }, '@/pages/investment/shared': { card: 'card', input: 'input' }, }; +function contextPreviewHarness() { + const memory = []; + let cursor = 0; + const hooks = { + ...React, + useState(initial) { + const index = cursor++; + if (!(index in memory)) + memory[index] = + typeof initial === 'function' ? initial() : initial; + return [ + memory[index], + (next) => { + memory[index] = + typeof next === 'function' ? next(memory[index]) : next; + }, + ]; + }, + useRef(initial) { + const index = cursor++; + if (!(index in memory)) memory[index] = { current: initial }; + return memory[index]; + }, + useEffect() {}, + }; + const pending = []; + const Editor = load('../../resources/js/pages/investor-path/context.tsx', { + react: hooks, + '@inertiajs/react': { Link: () => null, router: {} }, + '@/components/navigation-context': { + useNavigationContext: () => ({ refresh() {} }), + }, + '@/lib/operation-key': { + createOperationKey: () => 'synthetic-operation', + }, + '@/lib/investor-context': { + contextFieldNames: { + project_title: 'Título', + project_summary: 'Resumo', + }, + contextApi: (url, body) => + new Promise((resolve, reject) => + pending.push({ url, body, resolve, reject }), + ), + }, + '@/pages/investment/shared': { + Frame: () => null, + card: 'card', + input: 'input', + button: 'button', + }, + '@/components/investment-complements': { + default: () => null, + SelectedComplements: () => null, + complementInput: (value) => value, + }, + }); + const props = { + initial: { + case: { id: 'synthetic', version: 1, title: 'Synthetic' }, + context: { + selection: {}, + fields: { currency: 'MZN' }, + revision: 1, + }, + can_write: true, + results: [], + requests: [], + }, + options: { opportunities: [], sectors: [], parks: [] }, + source: null, + }; + return { + pending, + render() { + cursor = 0; + return Editor(props); + }, + result: { + preview_hash: 'synthetic-preview', + selection: {}, + maintained: [], + updated: [], + dependent_results: [], + review_steps: [], + requests: [], + documents_preserved: 0, + }, + }; +} +const buttonNamed = (tree, label) => + flat(tree).find( + (node) => node.type === 'button' && node.props.children === label, + ); +const settlePreview = () => new Promise((resolve) => setImmediate(resolve)); +void test('context comparison locks declaration and replacement controls until its matching preview is ready', async () => { + const harness = contextPreviewHarness(); + buttonNamed( + harness.render(), + 'Rever dados antes de guardar', + ).props.onClick(); + const busy = harness.render(); + assert.equal( + flat(busy) + .filter((node) => node.type === 'select') + .every((node) => node.props.disabled), + true, + ); + assert.equal( + flat(busy) + .filter( + (node) => + node.type === 'input' && + (node.props.maxLength || node.props.type === 'checkbox'), + ) + .every((node) => node.props.disabled), + true, + ); + assert.equal(harness.pending[0].body.version, 1); + harness.pending[0].resolve(harness.result); + await settlePreview(); + const ready = harness.render(); + assert.equal( + buttonNamed(ready, 'Guardar dados do investimento').props.disabled, + false, + ); + assert.equal( + flat(ready).find( + (node) => node.type === 'input' && node.props.maxLength === 3, + ).props.disabled, + false, + ); +}); +void test('context comparison rejects a response for a draft changed while the request was pending', async () => { + const harness = contextPreviewHarness(); + buttonNamed( + harness.render(), + 'Rever dados antes de guardar', + ).props.onClick(); + const busy = harness.render(); + // Simulate a queued state change, independently of native disabled controls. + flat(busy) + .find((node) => node.type === 'input' && node.props.maxLength === 3) + .props.onChange({ target: { value: 'USD' } }); + harness.render(); + harness.pending[0].resolve(harness.result); + await settlePreview(); + const stale = harness.render(); + assert.equal( + buttonNamed(stale, 'Guardar dados do investimento'), + undefined, + ); + assert.ok( + flat(stale).some( + (node) => + node.type === 'p' && + node.props.children === + 'Os dados mudaram durante a comparação. Reveja novamente antes de guardar.', + ), + ); + assert.equal( + flat(stale).find( + (node) => node.type === 'input' && node.props.maxLength === 3, + ).props.value, + 'USD', + ); +}); +void test('a failed replacement comparison cannot leave the earlier preview confirmable', async () => { + const harness = contextPreviewHarness(); + buttonNamed( + harness.render(), + 'Rever dados antes de guardar', + ).props.onClick(); + harness.render(); + harness.pending[0].resolve(harness.result); + await settlePreview(); + assert.ok(buttonNamed(harness.render(), 'Guardar dados do investimento')); + buttonNamed( + harness.render(), + 'Rever dados antes de guardar', + ).props.onClick(); + assert.equal( + buttonNamed(harness.render(), 'Guardar dados do investimento'), + undefined, + ); + harness.pending[1].reject(new Error('Synthetic request failed')); + await settlePreview(); + assert.equal( + buttonNamed(harness.render(), 'Guardar dados do investimento'), + undefined, + ); +}); void test('preparation directs complete context to missing documents without treating association or uncertainty as approval', () => { const Summary = load( '../../resources/js/components/private-investment-preparation.tsx',