Complete durable private Education review preparation and recovery
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
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.');
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user