Complete durable private Education review preparation and recovery
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-11-11 08:21:56 +02:00
parent bbc3c1c9bc
commit 6805b606a1
14 changed files with 1092 additions and 49 deletions
@@ -21,11 +21,13 @@ use App\Services\EducationWorkQueue;
use App\Services\InvestmentGeography;
use App\Services\PortalAudit;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -166,6 +168,7 @@ class EducationController extends Controller
Inertia::encryptHistory();
return tap(Inertia::render('education/declaration', ['declaration' => $row, 'kind' => $kind, 'actorId' => $request->user()->id, 'today' => now()->toDateString(),
'preparation' => app(EducationReviews::class)->preparation($request->user(), $record),
'history' => app(EducationReviews::class)->history($request->user(), $record),
'backUrl' => route('management.education.index', $filters, false), 'references' => $references])->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
@@ -203,6 +206,29 @@ class EducationController extends Controller
return back()->with('success', 'Declaração registada para revisão independente.');
}
public function reviewPreparation(Request $request, string $kind, string $id): JsonResponse
{
$service = app(EducationReviews::class);
app(EducationAccess::class)->authorize($request->user());
if ($request->isMethod('POST')) {
// Like CRM preparations, retain partial text before business validation.
$input = json_decode($request->getContent(), true);
if (! is_array($input)) {
throw ValidationException::withMessages(['snapshot' => 'A preparação deve ser enviada em JSON.']);
}
$view = $service->savePreparation($request->user(), $kind, $id, $input);
} else {
$expected = $request->validate(['actor_id' => 'sometimes|required|integer|min:1']);
abort_if(isset($expected['actor_id']) && (int) $expected['actor_id'] !== $request->user()->id, 409, 'A conta mudou. Actualize a entrada antes de recuperar.');
$record = match ($kind) {
'demands' => LabourDemand::findOrFail($id), 'capacities' => TrainingCapacity::findOrFail($id), default => abort(404)
};
$view = $service->preparation($request->user(), $record);
}
return response()->json($view)->header('Cache-Control', 'private, no-store');
}
public function review(Request $request, string $kind, string $id): RedirectResponse
{
$result = app(EducationReviews::class)->decide($request->user(), $kind, $id, $request->all());
+131
View File
@@ -6,10 +6,13 @@ use App\Models\LabourDemand;
use App\Models\TrainingCapacity;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use JsonException;
final class EducationReviews
{
@@ -55,6 +58,134 @@ final class EducationReviews
});
}
/** Private preparation only; never a decision or an authority grant.
* @return array<string,mixed> */
public function preparation(User $actor, LabourDemand|TrainingCapacity $record): array
{
app(EducationAccess::class)->authorize($actor);
$event = $this->preparations($actor, $record)->orderByRaw("CASE WHEN (after_data->'education_preparation'->>'version') ~ '^[0-9]{1,10}$' THEN (after_data->'education_preparation'->>'version')::bigint END DESC NULLS LAST")->orderByDesc('occurred_at')->orderByDesc('id')->first(['after_data']);
$value = $event ? json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['education_preparation'] : null;
$snapshot = null;
$unavailable = false;
if ($value) {
try {
$decoded = json_decode(Crypt::decryptString($value['snapshot']), true, 512, JSON_THROW_ON_ERROR);
$snapshot = Validator::make(['snapshot' => $decoded], $this->preparationRules($decoded))->validate()['snapshot'];
} catch (DecryptException|JsonException|ValidationException) {
$unavailable = true;
}
}
// Empty reference maps remain JSON objects in the browser, like the declaration props.
if (is_array($snapshot)) {
if ($snapshot['base']['references'] === []) {
$snapshot['base']['references'] = (object) [];
}
if (is_array($snapshot['pending']) && $snapshot['pending']['base']['references'] === []) {
$snapshot['pending']['base']['references'] = (object) [];
}
}
return ['actor_id' => $actor->id, 'kind' => $record instanceof LabourDemand ? 'demands' : 'capacities', 'record_id' => $record->id, 'version' => (int) ($value['version'] ?? 0), 'operation_key' => $value['operation_key'] ?? null,
'snapshot' => $snapshot, 'unavailable' => $unavailable];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function savePreparation(User $actor, string $kind, string $id, array $input): array
{
app(EducationAccess::class)->authorize($actor);
$model = match ($kind) {
'demands' => LabourDemand::class, 'capacities' => TrainingCapacity::class, default => abort(404)
};
$data = Validator::make($input, [...$this->preparationRules($input['snapshot'] ?? null),
'actor_id' => 'required|integer|min:1', 'version' => 'required|integer|min:0', 'operation_key' => 'required|uuid'])->validate();
abort_unless((int) $data['actor_id'] === $actor->id, 409, 'A conta mudou. Actualize a entrada antes de guardar a preparação.');
$key = strtolower($data['operation_key']);
$snapshot = $data['snapshot'];
foreach (['values', 'base'] as $field) {
$snapshot[$field]['version_no'] = (int) $snapshot[$field]['version_no'];
}
$snapshot['base']['quantity'] = (int) $snapshot['base']['quantity'];
foreach (['status', 'review_reason'] as $field) {
$snapshot['generation'][$field] = (int) $snapshot['generation'][$field];
}
if ($snapshot['pending'] !== null) {
$snapshot['pending']['values']['version_no'] = (int) $snapshot['pending']['values']['version_no'];
$snapshot['pending']['base']['version_no'] = (int) $snapshot['pending']['base']['version_no'];
$snapshot['pending']['base']['quantity'] = (int) $snapshot['pending']['base']['quantity'];
$snapshot['pending']['values']['client_key'] = strtolower($snapshot['pending']['values']['client_key']);
foreach (['reasonGeneration', 'statusGeneration'] as $field) {
$snapshot['pending'][$field] = (int) $snapshot['pending'][$field];
}
abort_unless($snapshot['pending']['values']['version_no'] === $snapshot['pending']['base']['version_no'], 422);
abort_unless($snapshot['pending']['reasonGeneration'] <= $snapshot['generation']['review_reason'] && $snapshot['pending']['statusGeneration'] <= $snapshot['generation']['status'], 422);
}
abort_unless($snapshot['values']['version_no'] === $snapshot['base']['version_no'], 422);
$hash = hash('sha256', json_encode(Arr::sortRecursive(['actor' => $actor->id, 'kind' => $kind, 'id' => $id,
'version' => (int) $data['version'], 'snapshot' => $snapshot]), JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $model, $id, $data, $snapshot, $key, $hash): array {
$record = $model::lockForUpdate()->findOrFail($id);
$previous = $this->preparations($actor, $record)->whereRaw("after_data->'education_preparation'->>'operation_key' = ?", [$key])->first(['after_data']);
if ($previous) {
$receipt = json_decode($previous->after_data, true, 512, JSON_THROW_ON_ERROR)['education_preparation'];
abort_unless(hash_equals($receipt['hash'], $hash), 409, 'Esta tentativa de guardar já foi usada com outros dados.');
return $this->preparation($actor, $record);
}
$current = $this->preparation($actor, $record);
abort_unless($current['version'] === (int) $data['version'], 409, 'A preparação mudou noutra janela. Compare antes de substituir.');
abort_if($current['unavailable'], 409, 'A preparação anterior não pôde ser recuperada e foi preservada.');
abort_if($snapshot['base']['version_no'] > (int) $record->getAttribute('version_no'), 422, 'A versão analisada não existe nesta declaração.');
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'actor_user_id' => $actor->id,
'entity_type' => $record->getTable(), 'entity_id' => $id, 'action' => 'review_preparation_saved', 'data_classification' => 'restricted',
'after_data' => json_encode(['education_preparation' => ['version' => $current['version'] + 1, 'operation_key' => $key, 'hash' => $hash,
'snapshot' => Crypt::encryptString(json_encode($snapshot, JSON_THROW_ON_ERROR))]], JSON_THROW_ON_ERROR)]);
return $this->preparation($actor, $record);
});
}
private function preparations(User $actor, LabourDemand|TrainingCapacity $record): Builder
{
return DB::table('audit_events')->where('source_system', self::SOURCE)->where('actor_user_id', $actor->id)
->where('entity_type', $record->getTable())->where('entity_id', $record->id)->where('action', 'review_preparation_saved');
}
/** @return array<string,string> */
private function preparationRules(mixed $snapshot): array
{
$rules = ['snapshot' => 'required|array:values,generation,base,pending', 'snapshot.values' => 'required|array:version_no,status,review_reason',
'snapshot.generation' => 'required|array:status,review_reason', 'snapshot.pending' => 'present|nullable|array:values,reasonGeneration,statusGeneration,base'];
$roots = ['snapshot'];
if (is_array($snapshot) && is_array($snapshot['pending'] ?? null)) {
$roots[] = 'snapshot.pending';
$rules['snapshot.pending.values'] = 'required|array:version_no,status,review_reason,client_key';
$rules['snapshot.pending.values.client_key'] = 'required|uuid';
foreach (['reasonGeneration', 'statusGeneration'] as $field) {
$rules['snapshot.pending.'.$field] = 'required|integer|min:0|max:1000000000';
}
}
foreach (['status', 'review_reason'] as $field) {
$rules['snapshot.generation.'.$field] = 'required|integer|min:0|max:1000000000';
}
foreach ($roots as $root) {
$rules[$root.'.values.version_no'] = 'required|integer|min:1';
$rules[$root.'.values.status'] = 'required|in:reviewed,rejected';
$rules[$root.'.values.review_reason'] = 'present|string|max:3000';
$rules[$root.'.base'] = 'required|array:version_no,qualification,quantity,status,period_start,period_end,valid_until,source,reference_date,employer,references';
$rules[$root.'.base.version_no'] = 'required|integer|min:1';
$rules[$root.'.base.quantity'] = 'required|integer|min:0';
foreach (['qualification' => 250, 'status' => 40, 'period_start' => 40, 'period_end' => 40, 'valid_until' => 40, 'source' => 5000, 'reference_date' => 40, 'employer' => 1000] as $field => $max) {
$rules[$root.'.base.'.$field] = 'present|string|max:'.$max;
}
$rules[$root.'.base.references'] = 'present|array:occupation,skill,project,course,location';
$rules[$root.'.base.references.*'] = 'nullable|string|max:1000';
}
return $rules;
}
/** @return array{actor_id:int,commands:list<array{key:string,version:int}>} */
public function context(User $actor, LabourDemand|TrainingCapacity $record): array
{
@@ -1,3 +1,11 @@
## 11/11/2026 — retoma durável da revisão de Educação em QA
A revisão interna de procura e capacidade pode guardar e recuperar a preparação privada, incluindo notas, versão analisada e intenção de decisão incerta. Reutiliza os eventos de auditoria existentes, com conteúdo cifrado e isolamento por conta, tipo e declaração. Guardar não decide: a decisão continua pelo escritor nativo, com revisão independente e validação de versão. A intenção fica persistida antes do envio; uma resposta perdida recupera a mesma chave, sem duplicar a decisão. Edições posteriores e preparações mais recentes noutra janela não são substituídas silenciosamente.
**QA concluído, ainda não instalado:** cinco testes nativos/105 asserções e dez testes novos de interface, com os 17 afectados preservados; análise PHP e tipos aprovados. A página nativa recuperada foi exercitada no componente real em harness: recibo reconhecido, edição posterior conservada e zero envios adicionais. Prova `outputs/education-review-preparation-20261111/verification.json`. Isto não constitui conferência visual no navegador. Sem migração, tabela, papel ou concessão nova.
**Saldo:** falta entregar este delta pelo processo Git e conferir a apresentação no uso normal. Jornadas externas de estudantes, formadoras e empregadores, representação e consentimento documentados permanecem pendentes; Educação não é declarada integralmente concluída. A homologação mantém `9c067d6a` e as 270 instituições publicadas. Permanecem explícitos os resíduos fiscais L1–L7/restante L8/L9–L16 e os restantes módulos. As provas aceites não foram reabertas.
## 11/11/2026 — combinação CBF15/créditos/PEC instalada em9c067d6a
Instalação expressamente autorizada nesta retoma e executada pelo processoGit existente: `9c067d6ae89c0cd1e9ad4f2813e84048988d3319`, conferida às07:43:38Africa/Johannesburg(05:43:38UTC). Dezficheiros do delta conferidos por hash; revisão,entrada,login,up,Concursos,fundos e simuladorHTTP200; configuração preservada, zero migrações/importações. Backup/restauro isolado conferido:190tabelas e369ficheiros. Nenhum restauro sobre dados activos.
@@ -1,3 +1,11 @@
## 11/11/2026 — retoma durável da revisão de Educação em QA
A revisão interna de procura e capacidade pode guardar e recuperar a preparação privada, incluindo notas, versão analisada e intenção de decisão incerta. Reutiliza os eventos de auditoria existentes, com conteúdo cifrado e isolamento por conta, tipo e declaração. Guardar não decide: a decisão continua pelo escritor nativo, com revisão independente e validação de versão. A intenção fica persistida antes do envio; uma resposta perdida recupera a mesma chave, sem duplicar a decisão. Edições posteriores e preparações mais recentes noutra janela não são substituídas silenciosamente.
**QA concluído, ainda não instalado:** cinco testes nativos/105 asserções e dez testes novos de interface, com os 17 afectados preservados; análise PHP e tipos aprovados. A página nativa recuperada foi exercitada no componente real em harness: recibo reconhecido, edição posterior conservada e zero envios adicionais. Prova `outputs/education-review-preparation-20261111/verification.json`. Isto não constitui conferência visual no navegador. Sem migração, tabela, papel ou concessão nova.
**Saldo:** falta entregar este delta pelo processo Git e conferir a apresentação no uso normal. Jornadas externas de estudantes, formadoras e empregadores, representação e consentimento documentados permanecem pendentes; Educação não é declarada integralmente concluída. A homologação mantém `9c067d6a` e as 270 instituições publicadas. Permanecem explícitos os resíduos fiscais L1–L7/restante L8/L9–L16 e os restantes módulos. As provas aceites não foram reabertas.
## 11/11/2026 — combinação CBF15/créditos/PEC instalada em9c067d6a
Instalação expressamente autorizada nesta retoma e executada pelo processoGit existente: `9c067d6ae89c0cd1e9ad4f2813e84048988d3319`, conferida às07:43:38Africa/Johannesburg(05:43:38UTC). Dezficheiros do delta conferidos por hash; revisão,entrada,login,up,Concursos,fundos e simuladorHTTP200; configuração preservada, zero migrações/importações. Backup/restauro isolado conferido:190tabelas e369ficheiros. Nenhum restauro sobre dados activos.
File diff suppressed because one or more lines are too long
@@ -1,3 +1,11 @@
## 11/11/2026 — retoma durável da revisão de Educação em QA
A revisão interna de procura e capacidade pode guardar e recuperar a preparação privada, incluindo notas, versão analisada e intenção de decisão incerta. Reutiliza os eventos de auditoria existentes, com conteúdo cifrado e isolamento por conta, tipo e declaração. Guardar não decide: a decisão continua pelo escritor nativo, com revisão independente e validação de versão. A intenção fica persistida antes do envio; uma resposta perdida recupera a mesma chave, sem duplicar a decisão. Edições posteriores e preparações mais recentes noutra janela não são substituídas silenciosamente.
**QA concluído, ainda não instalado:** cinco testes nativos/105 asserções e dez testes novos de interface, com os 17 afectados preservados; análise PHP e tipos aprovados. A página nativa recuperada foi exercitada no componente real em harness: recibo reconhecido, edição posterior conservada e zero envios adicionais. Prova `outputs/education-review-preparation-20261111/verification.json`. Isto não constitui conferência visual no navegador. Sem migração, tabela, papel ou concessão nova.
**Saldo:** falta entregar este delta pelo processo Git e conferir a apresentação no uso normal. Jornadas externas de estudantes, formadoras e empregadores, representação e consentimento documentados permanecem pendentes; Educação não é declarada integralmente concluída. A homologação mantém `9c067d6a` e as 270 instituições publicadas. Permanecem explícitos os resíduos fiscais L1–L7/restante L8/L9–L16 e os restantes módulos. As provas aceites não foram reabertas.
## 11/11/2026 — combinação CBF15/créditos/PEC instalada em9c067d6a
Instalação expressamente autorizada nesta retoma e executada pelo processoGit existente: `9c067d6ae89c0cd1e9ad4f2813e84048988d3319`, conferida às07:43:38Africa/Johannesburg(05:43:38UTC). Dezficheiros do delta conferidos por hash; revisão,entrada,login,up,Concursos,fundos e simuladorHTTP200; configuração preservada, zero migrações/importações. Backup/restauro isolado conferido:190tabelas e369ficheiros. Nenhum restauro sobre dados activos.
+41 -1
View File
@@ -16,6 +16,46 @@ export default function EducationReview(props: EducationReviewProps) {
aria-label="Revisão interna da declaração"
>
{edit.notice && <p role="status">{edit.notice}</p>}
{edit.preparationAvailable && (
<section
className="space-y-2 rounded border p-3"
aria-label="Preparação privada da revisão"
>
<p>
Guarde a preparação para retomar noutra entrada. É
privada desta conta e declaração; guardar não decide nem
aprova a declaração.
</p>
{edit.savedPreparation?.version ? (
<p>
Preparação guardada: versão{' '}
{edit.savedPreparation.version}.
</p>
) : (
<p>Ainda sem preparação privada guardada.</p>
)}
<button
type="button"
className={button}
disabled={edit.persisting}
onClick={() => void edit.persistPreparation()}
>
Guardar preparação da revisão
</button>
<button
type="button"
className={button}
disabled={edit.persisting || form.processing}
onClick={() => void edit.restorePreparation()}
>
Recuperar preparação guardada
</button>
{edit.persisting && (
<p role="status">A conferir a preparação privada…</p>
)}
</section>
)}
{(edit.stale || edit.conflict) && (
<section
className="space-y-3 rounded border p-4"
@@ -155,7 +195,7 @@ export default function EducationReview(props: EducationReviewProps) {
</h2>
{edit.uncertain && (
<p>
A recuperação repete a decisão enviada:{' '}
A recuperação mantém a mesma intenção de revisão:{' '}
{states[edit.sent?.status ?? ''] ?? 'Por confirmar'}
. Fundamento: {edit.sent?.review_reason}. As edições
posteriores ficam conservadas.
+310 -44
View File
@@ -1,6 +1,7 @@
import { router, useForm } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import { createOperationKey } from './operation-key';
import { profileJson } from './profile-json';
export type EducationDeclaration = {
id: string;
@@ -29,6 +30,7 @@ export type EducationReviewProps = {
kind: 'demands' | 'capacities';
actorId: number;
today: string;
preparation?: ReviewPreparation;
references?: Record<string, string | null>;
};
type Values = { version_no: number; status: string; review_reason: string };
@@ -53,6 +55,27 @@ type Attempt = {
statusGeneration: number;
base: Snapshot;
};
export type ReviewPreparationSnapshot = {
values: Values;
generation: { status: number; review_reason: number };
base: Snapshot;
pending: Attempt | null;
};
export type ReviewPreparation = {
actor_id: number;
kind: 'demands' | 'capacities';
record_id: string;
version: number;
operation_key: string | null;
snapshot: ReviewPreparationSnapshot | null;
unavailable: boolean;
};
type PreparationSave = {
actor_id: number;
version: number;
operation_key: string;
snapshot: ReviewPreparationSnapshot;
};
const object = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const positive = (value: unknown): value is number =>
@@ -79,6 +102,7 @@ export function useEducationReview({
kind,
actorId,
references = {},
preparation,
}: EducationReviewProps) {
const form = useForm<Values>({
version_no: row.version_no,
@@ -91,6 +115,8 @@ export function useEducationReview({
pending = useRef<Attempt | null>(null),
alive = useRef(true);
const generation = useRef({ status: 0, review_reason: 0 });
const observed = useRef({ row, references });
observed.current = { row, references };
const [uncertain, setUncertain] = useState(false),
[conflict, setConflict] = useState(false),
[refreshing, setRefreshing] = useState(false),
@@ -102,6 +128,229 @@ export function useEducationReview({
alive.current = false;
};
}, []);
const [savedPreparation, setSavedPreparation] = useState(
preparation ?? null,
);
const [persisting, setPersisting] = useState(false);
const preparationVersion = useRef(preparation?.version ?? 0);
const preparationSave = useRef<PreparationSave | null>(null);
const saving = useRef(false);
const preparationUrl = `/management/education/${kind}/${row.id}/review-preparation`;
function privateSnapshot(): ReviewPreparationSnapshot {
return structuredClone({
values: live.current,
generation: generation.current,
base: base.current,
pending: pending.current,
});
}
function validPreparation(value: unknown): value is ReviewPreparation {
const nonnegative = (n: unknown): n is number =>
typeof n === 'number' && Number.isSafeInteger(n) && n >= 0;
const values = (v: unknown): v is Values =>
object(v) &&
positive(v.version_no) &&
typeof v.review_reason === 'string' &&
['reviewed', 'rejected'].includes(String(v.status));
const evidence = (v: unknown): v is Snapshot =>
object(v) &&
positive(v.version_no) &&
nonnegative(v.quantity) &&
[
'qualification',
'status',
'period_start',
'period_end',
'valid_until',
'source',
'reference_date',
'employer',
].every((k) => typeof v[k] === 'string') &&
object(v.references) &&
Object.values(v.references).every(
(v) => v === null || typeof v === 'string',
);
if (
!object(value) ||
value.actor_id !== actorId ||
value.kind !== kind ||
value.record_id !== row.id ||
!nonnegative(value.version) ||
!(
value.operation_key === null ||
typeof value.operation_key === 'string'
) ||
typeof value.unavailable !== 'boolean'
)
return false;
if (value.snapshot === null) return true;
const state = value.snapshot;
if (
!object(state) ||
!values(state.values) ||
!evidence(state.base) ||
state.values.version_no !== state.base.version_no ||
!object(state.generation) ||
!nonnegative(state.generation.status) ||
!nonnegative(state.generation.review_reason)
)
return false;
return (
state.pending === null ||
(object(state.pending) &&
values(state.pending.values) &&
object(state.pending.values) &&
'client_key' in state.pending.values &&
typeof state.pending.values.client_key === 'string' &&
evidence(state.pending.base) &&
state.pending.values.version_no ===
state.pending.base.version_no &&
nonnegative(state.pending.reasonGeneration) &&
nonnegative(state.pending.statusGeneration))
);
}
async function persistPreparation(attempt?: Attempt): Promise<boolean> {
if (
invalid ||
preparation === undefined ||
saving.current ||
(attempt !== undefined && form.processing)
)
return false;
saving.current = true;
setPersisting(true);
try {
preparationSave.current ??= {
actor_id: actorId,
version: preparationVersion.current,
operation_key: createOperationKey(),
snapshot: privateSnapshot(),
};
const sent = preparationSave.current;
const value = await profileJson(preparationUrl, sent);
if (!alive.current || preparationSave.current !== sent)
return false;
if (!validPreparation(value))
throw new Error(
'A resposta da preparação não pôde ser conferida.',
);
if (value.unavailable || !value.snapshot)
throw new Error(
'A intenção guardada não pôde ser recuperada com segurança.',
);
setSavedPreparation(value);
if (value.operation_key !== sent.operation_key) {
setNotice(
'Há uma preparação mais recente noutra janela. Foi preservada; recupere e compare antes de continuar.',
);
return false;
}
preparationVersion.current = value.version;
preparationSave.current = null;
if (
attempt &&
sent.snapshot.pending?.values.client_key !==
attempt.values.client_key
) {
setNotice(
'A preparação anterior foi recuperada. Guarde a intenção mais recente antes de enviar a revisão.',
);
return false;
}
setNotice(
JSON.stringify(sent.snapshot) ===
JSON.stringify(privateSnapshot())
? 'Preparação privada guardada. Ainda não constitui uma decisão de revisão.'
: 'Preparação guardada; as edições feitas entretanto continuam por guardar.',
);
return true;
} catch (error) {
if (alive.current)
setNotice(
(error instanceof Error
? error.message
: 'Não foi possível guardar a preparação.') +
' A decisão não foi enviada. Repita a mesma gravação para recuperar a resposta incerta.',
);
return false;
} finally {
saving.current = false;
if (alive.current) setPersisting(false);
}
}
async function restorePreparation() {
if (
invalid ||
saving.current ||
form.processing ||
preparation === undefined
)
return;
saving.current = true;
setPersisting(true);
const before = JSON.stringify(privateSnapshot());
try {
const value = await profileJson(
`${preparationUrl}?actor_id=${actorId}`,
{},
'GET',
);
if (!alive.current) return;
if (!validPreparation(value))
throw new Error(
'A preparação guardada não pôde ser conferida.',
);
setSavedPreparation(value);
if (value.unavailable || !value.snapshot) {
setNotice(
value.unavailable
? 'A preparação guardada está indisponível e foi preservada.'
: 'Ainda não existe preparação guardada para esta conta e declaração.',
);
return;
}
if (before !== JSON.stringify(privateSnapshot())) {
setNotice(
'Conserve as novas edições: a preparação recuperada não as substituiu. Compare antes de recuperar novamente.',
);
return;
}
if (
(live.current.review_reason.trim() || pending.current) &&
!window.confirm(
'Substituir a preparação local pela versão privada guardada?',
)
)
return;
const state = structuredClone(value.snapshot);
preparationVersion.current = value.version;
preparationSave.current = null;
base.current = state.base;
generation.current = state.generation;
pending.current = state.pending;
live.current = state.values;
form.setData(state.values);
form.clearErrors();
setUncertain(state.pending !== null);
setConflict(false);
setAccessBlocked(false);
setNotice(
'Preparação recuperada. Confira a versão e a fonte actuais antes de enviar.',
);
if (state.pending)
acknowledge(observed.current.row, observed.current.references);
} catch (error) {
if (alive.current)
setNotice(
error instanceof Error
? error.message
: 'Não foi possível recuperar a preparação.',
);
} finally {
saving.current = false;
if (alive.current) setPersisting(false);
}
}
const invalid =
!positive(actorId) ||
row.review_context?.actor_id !== actorId ||
@@ -200,6 +449,7 @@ export function useEducationReview({
function submit() {
if (
invalid ||
saving.current ||
accessBlocked ||
refreshing ||
form.processing ||
@@ -226,53 +476,63 @@ export function useEducationReview({
const attempt = pending.current;
setUncertain(true);
setNotice('A aguardar confirmação da revisão.');
form.transform(() => attempt.values);
form.post(`/management/education/${kind}/${row.id}/review`, {
preserveScroll: true,
onSuccess: (page) => {
if (!alive.current || pending.current !== attempt) return;
const record = read(page.props),
labels = returnedReferences(page.props);
if (!record || !labels || !acknowledge(record, labels))
setNotice(
'A resposta não permitiu confirmar a revisão. Actualize a declaração ou recupere o mesmo envio.',
);
},
onError: () => {
if (!alive.current || pending.current !== attempt) return;
pending.current = null;
setUncertain(false);
setNotice(
'Reveja os campos assinalados. A decisão preparada foi conservada.',
);
},
onNetworkError: () => {
if (alive.current && pending.current === attempt)
setNotice(
'Sem confirmação da revisão. Recupere o mesmo envio antes de preparar outra decisão.',
);
},
onCancel: () => {
if (alive.current && pending.current === attempt)
setNotice(
'Envio interrompido sem confirmação. Recupere o mesmo envio.',
);
},
onHttpException: (response) => {
if (!alive.current || pending.current !== attempt) return;
if ([401, 403, 419].includes(response.status))
setAccessBlocked(true);
if (response.status === 422) {
const send = () => {
form.transform(() => attempt.values);
form.post(`/management/education/${kind}/${row.id}/review`, {
preserveScroll: true,
onSuccess: (page) => {
if (!alive.current || pending.current !== attempt) return;
const record = read(page.props),
labels = returnedReferences(page.props);
if (!record || !labels || !acknowledge(record, labels))
setNotice(
'A resposta não permitiu confirmar a revisão. Actualize a declaração ou recupere o mesmo envio.',
);
},
onError: () => {
if (!alive.current || pending.current !== attempt) return;
pending.current = null;
setUncertain(false);
}
if ([409, 422].includes(response.status)) setConflict(true);
setNotice(
'Não foi possível confirmar a revisão. Actualize a declaração e o acesso antes de continuar. A decisão preparada foi conservada.',
);
},
});
setNotice(
'Reveja os campos assinalados. A decisão preparada foi conservada.',
);
},
onNetworkError: () => {
if (alive.current && pending.current === attempt)
setNotice(
'Sem confirmação da revisão. Recupere o mesmo envio antes de preparar outra decisão.',
);
},
onCancel: () => {
if (alive.current && pending.current === attempt)
setNotice(
'Envio interrompido sem confirmação. Recupere o mesmo envio.',
);
},
onHttpException: (response) => {
if (!alive.current || pending.current !== attempt) return;
if ([401, 403, 419].includes(response.status))
setAccessBlocked(true);
if (response.status === 422) {
pending.current = null;
setUncertain(false);
}
if ([409, 422].includes(response.status)) setConflict(true);
setNotice(
'Não foi possível confirmar a revisão. Actualize a declaração e o acesso antes de continuar. A decisão preparada foi conservada.',
);
},
});
};
if (preparation !== undefined) {
setNotice('A conservar a intenção de revisão antes do envio.');
void persistPreparation(attempt).then((stored) => {
if (stored && alive.current && pending.current === attempt)
send();
});
} else send();
}
function refresh() {
if (refreshing || form.processing) return;
setRefreshing(true);
@@ -360,6 +620,11 @@ export function useEducationReview({
}, []);
return {
form,
persistPreparation: () => persistPreparation(),
restorePreparation,
preparationAvailable: preparation !== undefined,
savedPreparation,
persisting,
change,
submit,
refresh,
@@ -378,6 +643,7 @@ export function useEducationReview({
!refreshing &&
!form.processing,
blocked:
persisting ||
invalid ||
accessBlocked ||
refreshing ||
+1
View File
@@ -294,6 +294,7 @@ Route::middleware(['auth', 'verified', EnsureScopedPortalAdministration::class])
Route::get('{kind}/{id}', [EducationController::class, 'declaration'])->whereIn('kind',['demands','capacities'])->whereUuid('id')->name('declaration');
Route::post('catalog/{kind}', [EducationController::class, 'catalog'])->whereIn('kind', ['skills', 'occupations', 'providers', 'courses'])->name('catalog');
Route::post('{kind}', [EducationController::class, 'store'])->whereIn('kind', ['demands', 'capacities'])->name('store');
Route::match(['GET', 'POST'], '{kind}/{id}/review-preparation', [EducationController::class, 'reviewPreparation'])->whereIn('kind', ['demands','capacities'])->whereUuid('id');
Route::post('{kind}/{id}/review', [EducationController::class, 'review'])->whereIn('kind', ['demands', 'capacities'])->whereUuid('id')->name('review');
Route::get('demands/{id}/analysis', [EducationController::class, 'analysis'])->whereUuid('id')->name('analysis');
Route::post('demands/{id}/investment', [EducationInvestmentController::class, 'store'])->whereUuid('id')->name('investment');
@@ -0,0 +1,174 @@
<?php
namespace Tests\Feature;
use App\Models\Course;
use App\Models\LabourDemand;
use App\Models\Occupation;
use App\Models\Project;
use App\Models\Skill;
use App\Models\TrainingCapacity;
use App\Models\TrainingProvider;
use App\Models\User;
use App\Services\EducationReviews;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class EducationReviewPreparationTest extends TestCase
{
use DatabaseTransactions;
private User $reviewer;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
config(['education.enabled' => true]);
$identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
$this->assertSame(55441, (int) $identity->port);
$this->reviewer = $this->manager();
}
private function manager(): User
{
$u = User::factory()->create();
config(['portal.managers' => [...config('portal.managers', []), $u->email]]);
$this->actingAs($u);
return $u;
}
private function record(string $kind): LabourDemand|TrainingCapacity
{
$place = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $place, 'name' => 'SYNTHETIC province', 'code' => $place, 'location_type' => 'province']);
$data = ['occupation_id' => Occupation::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC occupation'])->id, 'skill_id' => Skill::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC skill'])->id,
'geo_location_id' => $place, 'qualification' => 'SYNTHETIC qualification', 'quantity' => 20, 'period_start' => today()->addMonth()->toDateString(), 'period_end' => today()->addMonths(2)->toDateString(),
'source' => 'SYNTHETIC documentary source', 'reference_date' => today()->toDateString(), 'valid_until' => today()->addYear()->toDateString(), 'submitted_by' => User::factory()->create()->id, 'status' => 'submitted', 'version_no' => 1];
if ($kind === 'demands') {
return LabourDemand::create([...$data, 'project_id' => Project::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC project', 'status' => 'draft'])->id, 'employer' => 'SYNTHETIC employer']);
}
$provider = TrainingProvider::create(['name' => 'SYNTHETIC provider', 'status' => 'candidate']);
$course = Course::create(['provider_id' => $provider->id, 'code' => (string) Str::uuid(), 'title' => 'SYNTHETIC course', 'status' => 'draft']);
return TrainingCapacity::create([...$data, 'course_id' => $course->id]);
}
private function state(LabourDemand|TrainingCapacity $record): array
{
$base = ['version_no' => 1, 'qualification' => 'SYNTHETIC qualification', 'quantity' => 20, 'status' => 'submitted', 'period_start' => $record->getRawOriginal('period_start'), 'period_end' => $record->getRawOriginal('period_end'),
'valid_until' => $record->getRawOriginal('valid_until'), 'source' => 'SYNTHETIC documentary source', 'reference_date' => today()->toDateString(), 'employer' => '', 'references' => []];
return ['values' => ['version_no' => 1, 'status' => 'reviewed', 'review_reason' => ' SENSITIVE SYNTHETIC PRIVATE REVIEW NOTE '], 'generation' => ['status' => 0, 'review_reason' => 1], 'base' => $base, 'pending' => null];
}
private function url(string $kind, LabourDemand|TrainingCapacity $r): string
{
return '/management/education/'.$kind.'/'.$r->id.'/review-preparation';
}
public function test_private_preparation_is_encrypted_actor_scoped_idempotent_and_preserves_the_declaration(): void
{
foreach (['demands', 'capacities'] as $kind) {
$record = $this->record($kind);
$before = $record->fresh()->getAttributes();
$url = $this->url($kind, $record);
$body = ['actor_id' => $this->reviewer->id, 'version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => $this->state($record)];
$response = $this->postJson($url, $body)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('version', 1);
$this->assertStringContainsString('"references":{}', $response->getContent());
$saved = $response->json();
$this->assertSame($body['snapshot'], $saved['snapshot']);
$this->postJson($url, $body)->assertOk()->assertJsonPath('version', 1);
$query = DB::table('audit_events')->where('source_system', 'portal-education-review')->where('entity_id', $record->id)->where('action', 'review_preparation_saved');
$this->assertSame(1, $query->count());
$this->assertStringNotContainsString('SENSITIVE SYNTHETIC PRIVATE REVIEW NOTE', $query->value('after_data'));
$this->getJson($url)->assertOk()->assertJsonPath('snapshot.values.review_reason', $body['snapshot']['values']['review_reason']);
$this->assertSame($before, $record->fresh()->getAttributes());
$this->assertSame(0, DB::table('audit_events')->where('entity_id', $record->id)->where('action', 'review_committed')->count());
$this->get('/management/education/'.$kind.'/'.$record->id)->assertOk()->assertInertia(fn ($page) => $page->where('preparation.version', 1)->where('history.total', 0));
$this->manager();
$this->getJson($url)->assertOk()->assertJsonPath('version', 0)->assertJsonPath('snapshot', null);
$this->actingAs($this->reviewer);
$this->postJson($url, [...$body, 'snapshot' => [...$body['snapshot'], 'values' => [...$body['snapshot']['values'], 'review_reason' => 'DIFFERENT NOTE USING THE SAME KEY']]])->assertConflict();
}
}
public function test_private_preparation_optimistic_version_and_late_replay_never_replace_newer_notes(): void
{
$r = $this->record('demands');
$url = $this->url('demands', $r);
$first = ['actor_id' => $this->reviewer->id, 'version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => $this->state($r)];
$this->postJson($url, $first)->assertOk();
$second = [...$first, 'version' => 1, 'operation_key' => (string) Str::uuid()];
$second['snapshot']['values']['review_reason'] = 'SYNTHETIC deliberate newer note from a second window.';
$this->postJson($url, $second)->assertOk()->assertJsonPath('version', 2);
$this->postJson($url, $first)->assertOk()->assertJsonPath('version', 2)->assertJsonPath('snapshot.values.review_reason', $second['snapshot']['values']['review_reason']);
$this->postJson($url, [...$first, 'operation_key' => (string) Str::uuid()])->assertConflict();
$this->assertSame(2, DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_preparation_saved')->count());
$other = $this->manager();
$this->postJson($url, $first)->assertConflict();
$this->getJson($url.'?actor_id='.$this->reviewer->id)->assertConflict();
$this->getJson($url)->assertOk()->assertJsonPath('snapshot', null)->assertJsonPath('actor_id', $other->id);
}
public function test_uncertain_review_is_recovered_from_its_private_intent_and_same_native_command_once(): void
{
$r = $this->record('demands');
$state = $this->state($r);
$key = (string) Str::uuid();
$state['pending'] = ['values' => [...$state['values'], 'client_key' => $key], 'reasonGeneration' => 1, 'statusGeneration' => 0, 'base' => $state['base']];
$state['values']['review_reason'] = 'SYNTHETIC later edit retained after an uncertain response.';
$state['generation']['review_reason'] = 2;
$url = $this->url('demands', $r);
$this->postJson($url, ['actor_id' => $this->reviewer->id, 'version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => $state])->assertOk();
$native = '/management/education/demands/'.$r->id.'/review';
$this->post($native, $state['pending']['values'])->assertRedirect();
$recovered = $this->getJson($url)->assertOk()->json('snapshot');
$this->assertSame($state, $recovered);
$this->post($native, $recovered['pending']['values'])->assertRedirect();
$this->assertSame(2, $r->fresh()->version_no);
$this->assertSame(1, DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_committed')->count());
$context = app(EducationReviews::class)->context($this->reviewer, $r->fresh());
$this->assertSame($key, $context['commands'][0]['key']);
$page = $this->get('/management/education/demands/'.$r->id)->assertOk()->viewData('page')['props'];
$directory = base_path('outputs/education-review-preparation-20261111');
if (! is_dir($directory)) {
mkdir($directory, 0700, true);
}
file_put_contents($directory.'/recovered-page.json', json_encode(['synthetic' => true, 'transactional_QA' => true, 'props' => array_intersect_key($page, array_flip(['declaration', 'kind', 'actorId', 'today', 'references', 'preparation', 'history', 'backUrl']))], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
public function test_unreadable_private_preparation_and_unauthorised_access_preserve_previous_bytes(): void
{
$r = $this->record('demands');
$url = $this->url('demands', $r);
$bytes = json_encode(['education_preparation' => ['actor_id' => $this->reviewer->id, 'version' => 1, 'operation_key' => (string) Str::uuid(), 'hash' => str_repeat('a', 64), 'snapshot' => 'SYNTHETIC unreadable original encrypted bytes']], JSON_THROW_ON_ERROR);
DB::table('audit_events')->insert(['source_system' => 'portal-education-review', 'actor_user_id' => $this->reviewer->id, 'entity_type' => 'labour_demands', 'entity_id' => $r->id, 'action' => 'review_preparation_saved', 'data_classification' => 'restricted', 'after_data' => $bytes]);
$stored = DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_preparation_saved')->value('after_data');
$this->getJson($url)->assertOk()->assertJsonPath('unavailable', true)->assertJsonPath('snapshot', null);
$this->postJson($url, ['actor_id' => $this->reviewer->id, 'version' => 1, 'operation_key' => (string) Str::uuid(), 'snapshot' => $this->state($r)])->assertConflict();
$this->assertSame($stored, DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_preparation_saved')->value('after_data'));
$this->actingAs(User::factory()->create());
$this->getJson($url)->assertForbidden();
$this->postJson($url, [])->assertForbidden();
}
public function test_invalid_decrypted_snapshots_are_unavailable_without_losing_the_encrypted_original(): void
{
foreach (['{', json_encode(['unexpected' => 'SYNTHETIC invalid snapshot'], JSON_THROW_ON_ERROR)] as $plain) {
$r = $this->record('demands');
$cipher = Crypt::encryptString($plain);
DB::table('audit_events')->insert(['source_system' => 'portal-education-review', 'actor_user_id' => $this->reviewer->id, 'entity_type' => 'labour_demands', 'entity_id' => $r->id, 'action' => 'review_preparation_saved', 'data_classification' => 'restricted',
'after_data' => json_encode(['education_preparation' => ['version' => 1, 'operation_key' => (string) Str::uuid(), 'hash' => str_repeat('a', 64), 'snapshot' => $cipher]], JSON_THROW_ON_ERROR)]);
$this->getJson($this->url('demands', $r))->assertOk()->assertJsonPath('unavailable', true)->assertJsonPath('snapshot', null);
$actual = json_decode(DB::table('audit_events')->where('entity_id', $r->id)->value('after_data'), true);
$this->assertSame($cipher, $actual['education_preparation']['snapshot']);
}
}
}
@@ -0,0 +1,300 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { harness, props, text } from './helpers/education-review.mjs';
const initial = {
actor_id: 1,
kind: 'demands',
record_id: props().declaration.id,
version: 0,
operation_key: null,
snapshot: null,
unavailable: false,
};
const reason = 'Justificação e evidência da revisão';
const receipt = (request) => ({
actor_id: request.data.actor_id,
kind: request.url.split('/')[3],
record_id: request.url.split('/')[4],
version: request.data.version + 1,
operation_key: request.data.operation_key,
snapshot: request.data.snapshot,
unavailable: false,
});
test('private review preparation is recovered after remount and never decides on save or restore', async () => {
const h = harness(props({ preparation: initial }));
h.change(
'education-review',
reason,
'SYNTHETIC deliberate reviewer note for later recovery.',
);
h.click('Guardar preparação da revisão');
assert.equal(h.sent.length, 0);
const request = h.preparations[0];
await h.preparationResponse(receipt(request));
const reopened = harness(props({ preparation: receipt(request) }));
assert.equal(reopened.field('education-review', reason).props.value, '');
reopened.click('Recuperar preparação guardada');
await reopened.preparationResponse(receipt(request));
assert.equal(
reopened.field('education-review', reason).props.value,
request.data.snapshot.values.review_reason,
);
assert.equal(reopened.sent.length, 0);
});
test('review waits for a durable intent; a lost preparation response retries exactly before one native decision', async () => {
const h = harness(props({ preparation: initial }));
h.change(
'education-review',
reason,
'SYNTHETIC proof of durable intent before native decision.',
);
h.submit('education-review');
assert.equal(h.sent.length, 0);
const request = h.preparations[0];
assert.ok(request.data.snapshot.pending.values.client_key);
await h.preparationFailure(
new Error('SYNTHETIC lost persistence response'),
);
assert.equal(h.sent.length, 0);
h.submit('education-review');
assert.deepEqual(h.preparations[1].data, request.data);
await h.preparationResponse(receipt(request));
assert.equal(h.sent.length, 1);
assert.equal(
h.sent[0].data.client_key,
request.data.snapshot.pending.values.client_key,
);
h.fail(h.sent[0]);
const fresh = harness(props({ preparation: receipt(request) }));
fresh.click('Recuperar preparação guardada');
await fresh.preparationResponse(receipt(request));
fresh.submit('education-review');
const resumed = fresh.preparations.at(-1);
await fresh.preparationResponse(receipt(resumed));
assert.deepEqual(fresh.sent[0].data, h.sent[0].data);
});
test('restoring an older declaration requires comparison and preserves deliberate edits during loading', async () => {
const h = harness(props({ preparation: initial }));
h.change(
'education-review',
reason,
'SYNTHETIC stored reasoning against the first version.',
);
h.click('Guardar preparação da revisão');
const saved = receipt(h.preparations[0]);
await h.preparationResponse(saved);
const fresh = harness(
props({
preparation: saved,
declaration: {
...h.props.declaration,
version_no: 2,
quantity: 40,
},
}),
);
fresh.click('Recuperar preparação guardada');
await fresh.preparationResponse(saved);
assert.match(text(fresh.tree), /A declaração mudou/);
fresh.submit('education-review');
assert.equal(fresh.sent.length, 0);
const loading = harness(props({ preparation: saved }));
loading.click('Recuperar preparação guardada');
loading.change(
'education-review',
reason,
'SYNTHETIC later edit while recovery response is pending.',
);
await loading.preparationResponse(saved);
assert.match(
loading.field('education-review', reason).props.value,
/later edit/,
);
assert.match(text(loading.tree), /não as substituiu/);
});
test('late private preparation responses cannot populate a different actor', async () => {
const h = harness(props({ preparation: initial }));
h.change(
'education-review',
reason,
'SYNTHETIC private content for the original reviewer only.',
);
h.click('Guardar preparação da revisão');
const request = h.preparations[0];
h.render({
...h.props,
actorId: 3,
declaration: {
...h.props.declaration,
review_context: { actor_id: 3, commands: [] },
},
preparation: initial,
});
await h.preparationResponse(receipt(request));
assert.equal(h.field('education-review', reason).props.value, '');
assert.equal(h.sent.length, 0);
});
test('a newer saved preparation in another window never causes the older intent to be submitted', async () => {
const h = harness(props({ preparation: initial }));
h.change(
'education-review',
reason,
'SYNTHETIC first window prepares a review.',
);
h.submit('education-review');
const request = h.preparations[0];
await h.preparationResponse({
...receipt(request),
version: 4,
operation_key: 'another-window',
});
assert.equal(h.sent.length, 0);
assert.match(text(h.tree), /noutra janela/);
h.submit('education-review');
assert.deepEqual(
h.preparations.at(-1).data,
request.data,
'A newer server version must not be adopted silently',
);
});
test('restoring a committed uncertain attempt acknowledges the native receipt and retains edits after sending', async () => {
const h = harness(props({ preparation: initial }));
h.change(
'education-review',
reason,
'SYNTHETIC original submitted reason.',
);
h.submit('education-review');
const request = h.preparations[0];
await h.preparationResponse(receipt(request));
h.fail(h.sent[0]);
h.change(
'education-review',
reason,
'SYNTHETIC later deliberate note after lost native response.',
);
h.click('Guardar preparação da revisão');
const saved = receipt(h.preparations.at(-1));
await h.preparationResponse(saved);
const command = request.data.snapshot.pending.values.client_key;
const fresh = harness(
props({
preparation: saved,
declaration: {
...h.props.declaration,
version_no: 2,
status: 'reviewed',
review_context: {
actor_id: 1,
commands: [{ key: command, version: 2 }],
},
},
}),
);
fresh.click('Recuperar preparação guardada');
await fresh.preparationResponse(saved);
assert.equal(fresh.sent.length, 0);
assert.match(
fresh.field('education-review', reason).props.value,
/later deliberate note/,
);
assert.match(text(fresh.tree), /Revisão registada/);
});
test('a private preparation response for another account or declaration never unlocks a review', async () => {
for (const patch of [
{ actor_id: 2 },
{ record_id: 'another-record' },
{ kind: 'capacities' },
]) {
const h = harness(props({ preparation: initial }));
h.change(
'education-review',
reason,
'SYNTHETIC note belongs to this reviewer and declaration only.',
);
h.submit('education-review');
const request = h.preparations[0];
await h.preparationResponse({ ...receipt(request), ...patch });
assert.equal(h.sent.length, 0);
assert.match(text(h.tree), /não pôde ser conferida/);
}
});
test('an unreadable persistence receipt cannot release the business decision', async () => {
const h = harness(props({ preparation: initial }));
h.change(
'education-review',
reason,
'SYNTHETIC intent must remain recoverable before deciding.',
);
h.submit('education-review');
await h.preparationResponse({
...receipt(h.preparations[0]),
snapshot: null,
unavailable: true,
});
assert.equal(h.sent.length, 0);
assert.match(text(h.tree), /não pôde ser recuperada/);
});
test('edits made while a native decision is in flight can be saved without a second decision', async () => {
const h = harness(props({ preparation: initial }));
h.change(
'education-review',
reason,
'SYNTHETIC original native decision reason.',
);
h.submit('education-review');
const first = h.preparations[0];
await h.preparationResponse(receipt(first));
assert.equal(h.sent[0].form.processing, true);
h.change(
'education-review',
reason,
'SYNTHETIC later note while native response is pending.',
);
h.click('Guardar preparação da revisão');
const during = h.preparations.at(-1);
assert.equal(
during.data.snapshot.pending.values.client_key,
h.sent[0].data.client_key,
);
assert.match(during.data.snapshot.values.review_reason, /later note/);
await h.preparationResponse(receipt(during));
assert.equal(h.sent.length, 1);
});
test('a receipt observed during recovery acknowledges the current declaration instead of a captured old page', async () => {
const h = harness(props({ preparation: initial }));
h.change(
'education-review',
reason,
'SYNTHETIC a review whose response was uncertain.',
);
h.submit('education-review');
const request = h.preparations[0];
await h.preparationResponse(receipt(request));
const fresh = harness(props({ preparation: receipt(request) }));
fresh.click('Recuperar preparação guardada');
fresh.render({
...fresh.props,
declaration: {
...fresh.props.declaration,
version_no: 2,
status: 'reviewed',
review_context: {
actor_id: 1,
commands: [
{
key: request.data.snapshot.pending.values.client_key,
version: 2,
},
],
},
},
});
await fresh.preparationResponse(receipt(request));
assert.equal(fresh.sent.length, 0);
assert.match(text(fresh.tree), /Revisão registada/);
});
@@ -61,6 +61,7 @@ export function harness(initial = props()) {
const frames = new Map(),
sent = [],
reloads = [],
preparations = [],
before = new Set(),
unload = new Set();
let active,
@@ -234,6 +235,18 @@ export function harness(initial = props()) {
taskStates: { open: 'Aberta', done: 'Conclu?da' },
},
};
mocks['./profile-json'] = {
profileJson: (url, data = {}, method = 'POST') =>
new Promise((resolve, reject) =>
preparations.push({
url,
data: clone(data),
method,
resolve,
reject,
}),
),
};
const operation = {
createOperationKey: () =>
`00000000-0000-4000-8000-${String(++sequence).padStart(12, '0')}`,
@@ -366,6 +379,23 @@ export function harness(initial = props()) {
render();
return {
sent,
preparations,
async preparationResponse(value, request = preparations.at(-1)) {
const previousWindow = globalThis.window;
globalThis.window = windowMock;
try {
request.resolve(clone(value));
await new Promise((resolve) => setImmediate(resolve));
render();
} finally {
globalThis.window = previousWindow;
}
},
async preparationFailure(error, request = preparations.at(-1)) {
request.reject(error);
await new Promise((resolve) => setImmediate(resolve));
render();
},
reloads,
render,
get props() {
@@ -0,0 +1,17 @@
import fs from 'node:fs';
import assert from 'node:assert/strict';
import { harness,props,text } from '../tests/frontend/helpers/education-review.mjs';
const out=new URL('../outputs/education-review-preparation-20261111/',import.meta.url);
const source=JSON.parse(fs.readFileSync(new URL('recovered-page.json',out),'utf8'));
assert.equal(source.synthetic,true);
const h=harness(props(source.props));
assert.equal(h.sent.length,0);
h.click('Recuperar preparação guardada');
await h.preparationResponse(source.props.preparation);
assert.equal(h.sent.length,0);
assert.match(text(h.tree),/Revisão registada/);
assert.match(h.field('education-review','Justificação e evidência da revisão').props.value,/later edit retained/);
const escape = value => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
fs.writeFileSync(new URL('recovered-review.html',out), '<!doctype html><meta charset="utf-8"><title>Prova de recuperação QA</title><h1>Recuperação sintética em QA</h1><p>Transcrição do componente no harness; não constitui conferência visual no navegador.</p><pre>' + escape(text(h.tree)) + '</pre>');
fs.writeFileSync(new URL('verification.json',out),JSON.stringify({synthetic:true,nativeRecoveredPage:true,actualComponent:true,privateActorBound:true,recordId:source.props.declaration.id,kind:source.props.kind,acknowledgedVersion:source.props.declaration.version_no,retainedLaterEdit:true,additionalBusinessPosts:h.sent.length,visualBrowserReview:false,activeDataChanged:false},null,2));
console.log('Native QA page recovered through actual review component; later edits retained, no duplicate decision.');
+34
View File
@@ -0,0 +1,34 @@
<?php
$root = dirname(__DIR__);
if (! in_array(($argv[1] ?? ''), ['test', 'analyse'], true) || basename($root) !== 'portal-investidor') {
throw new RuntimeException('Explicit local test operation required.');
}
foreach (['APP_ENV' => 'testing', 'APP_KEY' => 'base64:'.base64_encode(str_repeat('Q', 32)),
'APP_CONFIG_CACHE' => $root.'/outputs/education-review-preparation-20261111/no-config.php',
'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441',
'DB_DATABASE' => 'simulator_fiscal_delta_20261021_test', 'DB_USERNAME' => 'investmoz_test', 'DB_PASSWORD' => '',
'DB_URL' => '', 'DATABASE_URL' => '', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array',
'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array', 'SUITECRM_ENABLED' => 'false'] as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $_SERVER[$key] = $value;
}
$pdo = new PDO('pgsql:host=127.0.0.1;port=55441;dbname=simulator_fiscal_delta_20261021_test', 'investmoz_test', '');
$identity = $pdo->query('select current_database() as name,inet_server_port() as port')->fetch(PDO::FETCH_ASSOC);
if ($identity['name'] !== 'simulator_fiscal_delta_20261021_test' || (int) $identity['port'] !== 55441) {
throw new RuntimeException('Isolated QA identity required.');
}
if (($argv[1] ?? '') === 'analyse') {
$argv = ['phpstan', 'analyse', '--no-progress', '--memory-limit=512M', '--configuration='.$root.'/outputs/funding-directory-20261110/phpstan.neon', 'app/Services/EducationReviews.php', 'app/Http/Controllers/EducationController.php'];
$_SERVER['argv'] = $argv;
$_SERVER['argc'] = count($argv);
require $root.'/vendor/autoload.php';
require $root.'/vendor/phpstan/phpstan/phpstan';
exit;
}
$argv = ['phpunit', '-c', 'phpunit.investmoz.xml', '--do-not-cache-result', 'tests/Feature/EducationReviewPreparationTest.php'];
$argc = count($argv);
$_SERVER['argv'] = $argv;
$_SERVER['argc'] = $argc;
require $root.'/vendor/autoload.php';
require $root.'/vendor/phpunit/phpunit/phpunit';