feat(opportunities): curate highlights with native authority and audited confirmation

This commit is contained in:
2026-11-10 10:41:50 +02:00
parent dcecd25102
commit 7f13149723
10 changed files with 688 additions and 0 deletions
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers;
use App\Services\OpportunityCuration;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class OpportunityCurationController extends Controller
{
public function show(Request $request, int $id, OpportunityCuration $service): Response
{
$base = str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities';
$filters = $request->validate(['page' => 'nullable|integer|min:1|max:1000000']);
return Inertia::render('management/opportunity-curation', [...$service->state($request->user(), $id, (int) ($filters['page'] ?? 1)), 'basePath' => $base]);
}
public function preview(Request $request, int $id, OpportunityCuration $service): JsonResponse
{
return response()->json($service->preview($request->user(), $id, $request->all()))->header('Cache-Control', 'private, no-store');
}
public function confirm(Request $request, int $id, OpportunityCuration $service): JsonResponse
{
return response()->json($service->confirm($request->user(), $id, $request->all()))->header('Cache-Control', 'private, no-store');
}
}
@@ -8,6 +8,7 @@ use App\Models\Sector;
use App\Services\InvestmentGeography;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityCanonicalResponse;
use App\Services\OpportunityCuration;
use App\Services\OpportunityManagement;
use App\Services\OpportunityManagementInstallation;
use App\Services\OpportunityMaturity;
@@ -130,6 +131,8 @@ class OpportunityManagementController extends Controller
$access->authorize($request->user(), 'view', $record);
}
Inertia::share(['basePath' => str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities',
'curationHref' => $record && app(OpportunityCuration::class)->allowed($request->user(), $record)
? (str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades/' : '/management/opportunities/').$record->id.'/destaque' : null,
'canonicalOptions' => ($record?->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades')) ? app(OpportunityCanonicalContract::class)->options($request->user(), $record) : null,
'workflowState' => $record ? $access->state($record) : null,
'profileReauthenticationRequired' => $record && $record->owner_user_id !== null
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class OpportunityCuration
{
public function __construct(private PortalProfileAccess $access, private OpportunityManagement $management) {}
public function allowed(User $actor, Opportunity $record): bool
{
if (! $this->management->installed() || ! $this->access->canView($actor, $record)) {
return false;
}
if ($record->owner_user_id === null) {
return $this->access->legacyWriteAllowed($actor);
}
return $this->access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category)
&& $this->access->operational($actor, 'publish', $record->id) && ! $this->access->conflict($actor, $record)
&& (int) data_get($record->workflow_context, 'approval.actor_id') !== $actor->id
&& (! data_get($record->workflow_context, 'publication_assignment.assignee_user_id')
|| (int) data_get($record->workflow_context, 'publication_assignment.assignee_user_id') === $actor->id);
}
private function authorize(User $actor, Opportunity $record): void
{
abort_unless($this->allowed($actor, $record), 403);
if ($record->owner_user_id !== null) {
app(OpportunityProfileWorkflow::class)->authorizeEditorialCuration($actor, $record);
}
}
/** @return array<string,mixed> */
public function state(User $actor, int $id, int $page = 1): array
{
$record = Opportunity::findOrFail($id);
$this->authorize($actor, $record);
$history = DB::table('audit_events')->where('source_system', 'opportunity-curation')->where('after_data->record_id', $id)
->orderByDesc('id')->paginate(25, ['id', 'actor_user_id', 'occurred_at', 'after_data'], 'page', $page)->through(function ($row) {
$data = json_decode($row->after_data, true, 512, JSON_THROW_ON_ERROR);
return ['id' => $row->id, 'actor' => $row->actor_user_id, 'at' => $row->occurred_at,
'featured' => $data['featured'], 'reason' => Crypt::decryptString($data['reason'])];
});
return ['record' => ['id' => $record->id, 'title' => $record->title, 'featured' => $record->is_featured],
'etag' => $this->management->etag($record), 'history' => $history];
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function preview(User $actor, int $id, array $input): array
{
$data = $this->validate($input);
$record = Opportunity::findOrFail($id);
$this->authorize($actor, $record);
$this->check($record, $data);
return ['hash' => $this->hash($actor, $id, $data), 'title' => $record->title,
'before' => $record->is_featured, 'featured' => $data['featured'], 'reason' => $data['reason']];
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function confirm(User $actor, int $id, array $input): array
{
$data = $this->validate($input);
Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted'])->validate();
abort_unless(hash_equals($this->hash($actor, $id, $data), $input['hash']), 409, 'A conferência mudou; prepare nova prévia.');
return DB::transaction(function () use ($actor, $id, $data) {
$record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail();
$this->authorize($actor, $record);
$receipt = DB::table('audit_events')->where('source_system', 'opportunity-curation')->where('after_data->record_id', $id)
->where('after_data->client_key', $data['client_key'])->first(['actor_user_id', 'after_data']);
$intent = $this->hash($actor, $id, $data);
if ($receipt) {
$prior = json_decode($receipt->after_data, true, 512, JSON_THROW_ON_ERROR);
abort_unless((int) $receipt->actor_user_id === $actor->id && hash_equals($prior['intent'], $intent), 409, 'Chave já utilizada com outro conteúdo.');
return ['replayed' => true, 'featured' => $record->is_featured, 'etag' => $this->management->etag($record)];
}
$this->check($record, $data);
$before = $record->is_featured;
$record->management_version++;
PortalAudit::save($record, ['is_featured' => $data['featured']], $actor->id);
DB::table('audit_events')->insert(['source_system' => 'opportunity-curation', 'entity_type' => 'opportunities',
'actor_user_id' => $actor->id, 'action' => 'featured_changed', 'data_classification' => 'restricted',
'before_data' => json_encode(['record_id' => $id, 'featured' => $before], JSON_THROW_ON_ERROR),
'after_data' => json_encode(['record_id' => $id, 'featured' => $record->is_featured, 'version' => $record->management_version,
'reason' => Crypt::encryptString($data['reason']), 'client_key' => $data['client_key'], 'intent' => $intent], JSON_THROW_ON_ERROR)]);
return ['replayed' => false, 'featured' => $record->is_featured, 'etag' => $this->management->etag($record)];
});
}
/** @param array<string,mixed> $input
* @return array{etag:string,client_key:string,featured:bool,reason:string}
*/
private function validate(array $input): array
{
$data = Validator::make($input, ['etag' => 'required|string|size:64', 'client_key' => 'required|uuid',
'featured' => 'required|boolean', 'reason' => 'required|string|min:20|max:1000'])->validate();
return ['etag' => $data['etag'], 'client_key' => $data['client_key'],
'featured' => (bool) $data['featured'], 'reason' => $data['reason']];
}
/** @param array{etag:string,client_key:string,featured:bool,reason:string} $data */
private function check(Opportunity $record, array $data): void
{
abort_unless(hash_equals($this->management->etag($record), $data['etag']), 409, 'A oportunidade mudou; recupere a versão actual antes de confirmar.');
abort_if($record->is_featured === $data['featured'], 422, 'O estado de destaque já corresponde à escolha.');
if ($data['featured']) {
abort_unless(Opportunity::published()->whereKey($record->id)->exists(), 409, 'Só uma oportunidade actualmente pública pode entrar em destaque.');
}
}
/** @param array{etag:string,client_key:string,featured:bool,reason:string} $data */
private function hash(User $actor, int $id, array $data): string
{
return hash_hmac('sha256', json_encode([$actor->id, $id, $data], JSON_THROW_ON_ERROR), (string) config('app.key'));
}
}
@@ -16,6 +16,11 @@ class OpportunityProfileWorkflow
public function __construct(private PortalProfileAccess $access, private OpportunityManagement $management,
private OpportunityCanonicalContract $contract, private OpportunityWorkflowTasks $tasks) {}
public function authorizeEditorialCuration(User $actor, Opportunity $record): void
{
$this->authorizeAction($actor, $record, 'publish');
}
/** @return list<string> */
public function actions(User $actor, Opportunity $record): array
{
+2
View File
@@ -22,6 +22,8 @@ class PortalProfileAccess
app_path('Services/PortalProfileAdministration.php'), app_path('Services/PortalSuperusers.php'),
app_path('Services/OpportunityProfileWorkflow.php'), app_path('Http/Controllers/PortalProfileController.php'), app_path('Http/Controllers/DashboardController.php'),
app_path('Http/Controllers/OpportunityManagementController.php'),
app_path('Services/OpportunityCuration.php'), app_path('Http/Controllers/OpportunityCurationController.php'),
resource_path('js/pages/management/opportunity-curation.tsx'),
app_path('Services/OpportunityCanonicalContract.php'), app_path('Services/OpportunityCanonicalResponse.php'), app_path('Services/OpportunityCategoryFields.php'), app_path('Services/OpportunityModularProfiles.php'), app_path('Services/OpportunityWorkflowTasks.php'), base_path('composer.json'), base_path('composer.lock'),
app_path('Services/OpportunityReviewMateriality.php'), app_path('Services/OpportunityReviewConditions.php'), app_path('Services/OpportunityCanonicalMetadata.php'), app_path('Services/OpportunityCompleteness.php'), app_path('Services/OpportunityCanonicalValue.php'),
resource_path('data/investmoz-opportunity-modular.schema.json'), resource_path('js/components/opportunity-canonical-fields.tsx'), resource_path('js/components/opportunity-parcel-geometry.tsx'),
@@ -0,0 +1,284 @@
import { Link, router } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import { createOperationKey } from '@/lib/operation-key';
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
import { Management, button, control } from './shared';
import type { Paginated } from './shared';
type Preview = {
hash: string;
title: string;
before: boolean;
featured: boolean;
reason: string;
};
export default function OpportunityCuration({
record,
etag: initialEtag,
history,
basePath,
}: {
record: { id: number; title: string; featured: boolean };
etag: string;
history: Paginated<{
id: number;
actor: number;
at: string;
featured: boolean;
reason: string;
}>;
basePath: string;
}) {
const [featured, setFeatured] = useState(!record.featured);
const [currentFeatured, setCurrentFeatured] = useState(record.featured);
const [reason, setReason] = useState('');
const [etag, setEtag] = useState(initialEtag);
const [key, setKey] = useState(createOperationKey);
const [preview, setPreview] = useState<Preview | null>(null);
const [confirmed, setConfirmed] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const inFlight = useRef(false);
const epoch = useRef(0);
const seenRecord = useRef(record.id);
const feedback = useRef<HTMLDivElement>(null);
const endpoint = `${basePath}/${record.id}/destaque`;
useEffect(() => {
if (seenRecord.current !== record.id) {
setReason('');
setMessage(
'Foi aberta outra oportunidade; prepare uma decisão própria.',
);
} else if (etag !== initialEtag) {
setMessage(
'A versão observada mudou; o fundamento foi conservado e precisa de nova prévia.',
);
}
seenRecord.current = record.id;
epoch.current++;
inFlight.current = false;
setBusy(false);
setEtag(initialEtag);
setFeatured(!record.featured);
setCurrentFeatured(record.featured);
setPreview(null);
setConfirmed(false);
setKey(createOperationKey());
}, [record.id, initialEtag, record.featured]);
function invalidate() {
setPreview(null);
setConfirmed(false);
setKey(createOperationKey());
}
async function send(confirm: boolean) {
if (inFlight.current || (confirm && (!preview || !confirmed))) return;
inFlight.current = true;
setBusy(true);
setErrors({});
setMessage('');
const currentEpoch = epoch.current;
try {
const result = await profileJson(
`${endpoint}/${confirm ? 'confirmar' : 'previa'}`,
{
etag,
client_key: key,
featured,
reason,
...(confirm
? { hash: preview?.hash, confirm: confirmed }
: {}),
},
);
if (epoch.current !== currentEpoch) return;
if (confirm) {
setPreview(null);
setConfirmed(false);
setEtag(result.etag);
setCurrentFeatured(result.featured);
setFeatured(!result.featured);
setKey(createOperationKey());
setReason('');
setMessage(
result.replayed
? 'Decisão já registada. O estado actual é recuperado.'
: 'Decisão de destaque registada.',
);
router.reload({
only: ['record', 'etag', 'history'],
});
} else {
setPreview(result);
setConfirmed(false);
}
} catch (error) {
if (epoch.current !== currentEpoch) return;
setMessage(
error instanceof Error
? error.message
: 'Não foi possível concluir. Repita sem editar para recuperar a decisão.',
);
if (error instanceof ProfileValidationError)
setErrors(error.fields);
} finally {
if (epoch.current === currentEpoch) {
inFlight.current = false;
setBusy(false);
feedback.current?.focus();
}
}
}
return (
<Management title="Curadoria de destaque">
<h1 className="text-2xl font-bold">{record.title}</h1>
<p>
Estado actual:{' '}
{currentFeatured ? 'Em destaque' : 'Sem destaque'}. A curadoria
não publica uma oportunidade nem aprova o investimento.
</p>
<div ref={feedback} tabIndex={-1} role="status" aria-live="polite">
{message}
</div>
<fieldset disabled={busy} className="space-y-4 rounded border p-4">
<legend>Decisão editorial individual</legend>
<label className="block">
Escolha
<select
className={control}
value={featured ? 'yes' : 'no'}
onChange={(event) => {
setFeatured(event.target.value === 'yes');
invalidate();
}}
>
<option value="yes">Colocar em destaque</option>
<option value="no">Retirar do destaque</option>
</select>
</label>
<label className="block">
Fundamento
<textarea
className={control}
value={reason}
minLength={20}
maxLength={1000}
onChange={(event) => {
setReason(event.target.value);
invalidate();
}}
/>
</label>
{Object.entries(errors).map(([field, value]) => (
<p key={field} role="alert">
{value}
</p>
))}
<button
type="button"
className={button}
onClick={() => send(false)}
>
Conferir prévia
</button>
{preview && (
<section
className="space-y-3 rounded border p-4"
aria-label="Prévia da decisão editorial"
>
<h2 className="font-bold">{preview.title}</h2>
<p>
{preview.before ? 'Em destaque' : 'Sem destaque'} →{' '}
{preview.featured ? 'Em destaque' : 'Sem destaque'}
</p>
<p className="whitespace-pre-wrap">{preview.reason}</p>
<label className="block">
<input
type="checkbox"
checked={confirmed}
onChange={(event) =>
setConfirmed(event.target.checked)
}
/>{' '}
Confirmo esta decisão e o fundamento apresentado.
</label>
<button
type="button"
className={button}
disabled={!confirmed}
onClick={() => send(true)}
>
Confirmar decisão de destaque
</button>
</section>
)}
<p>
Se a resposta se perder, repita a confirmação sem editar os
campos.
</p>
</fieldset>
{!busy && (
<Link
href={`${basePath}/${record.id}/edit`}
className="underline"
>
Voltar à mesma oportunidade
</Link>
)}
<section className="space-y-3">
<h2 className="text-xl font-bold">
Histórico de decisões de destaque — página{' '}
{history.current_page}
</h2>
{history.data.length ? (
history.data.map((item) => (
<article key={item.id} className="rounded border p-3">
<p>
{item.at} · Responsável {item.actor} ·{' '}
{item.featured
? 'Em destaque'
: 'Destaque retirado'}
</p>
<p className="whitespace-pre-wrap">{item.reason}</p>
</article>
))
) : (
<p>
Ainda sem decisões de curadoria registadas neste
percurso.
</p>
)}
{!busy && (
<nav
aria-label="Páginas do histórico de curadoria"
className="flex gap-4"
>
{history.prev_page_url && (
<Link
href={history.prev_page_url}
preserveState
preserveScroll
>
Anterior
</Link>
)}
{history.next_page_url && (
<Link
href={history.next_page_url}
preserveState
preserveScroll
>
Seguinte
</Link>
)}
</nav>
)}
</section>
</Management>
);
}
@@ -97,10 +97,12 @@ export default function OpportunityDraft({
publicationPreview,
publicationState,
promotionHref,
curationHref,
auth,
canEditDraft = false,
} = usePage<{
promotionHref?: string | null;
curationHref?: string | null;
basePath?: string;
profileMode?: boolean;
profileOrganization?: string | null;
@@ -306,6 +308,11 @@ export default function OpportunityDraft({
}
>
{!profileMode && <Link href={basePath}>Voltar à gestão</Link>}
{curationHref && (
<Link className="ml-3 underline" href={curationHref}>
Curadoria de destaque
</Link>
)}
{promotionHref && (
<Link className="ml-3 underline" href={promotionHref}>
Divulgação promocional — Em preparação
+7
View File
@@ -32,6 +32,7 @@ use App\Http\Controllers\InvestmentTerritoryController;
use App\Http\Controllers\InvestorWorkspaceController;
use App\Http\Controllers\LegacyContentController;
use App\Http\Controllers\OpportunityClassificationController;
use App\Http\Controllers\OpportunityCurationController;
use App\Http\Controllers\OpportunityManagementController;
use App\Http\Controllers\OpportunityOriginReviewController;
use App\Http\Controllers\OpportunityPromotionController;
@@ -220,6 +221,9 @@ Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(func
Route::get('/minhas-oportunidades/{id}/edit', [$profiles, 'edit'])->whereNumber('id');
Route::get('/minhas-oportunidades/{id}/view', [$profiles, 'edit'])->whereNumber('id');
Route::get('/minhas-oportunidades/{id}/response', [OpportunityManagementController::class, 'canonicalResponse'])->whereNumber('id');
Route::get('/minhas-oportunidades/{id}/destaque', [OpportunityCurationController::class, 'show'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
Route::post('/minhas-oportunidades/{id}/destaque/previa', [OpportunityCurationController::class, 'preview'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
Route::post('/minhas-oportunidades/{id}/destaque/confirmar', [OpportunityCurationController::class, 'confirm'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
Route::post('/minhas-oportunidades/drafts', [OpportunityManagementController::class, 'save']);
Route::put('/minhas-oportunidades/{id}/draft', [OpportunityManagementController::class, 'save'])->whereNumber('id');
Route::get('/minhas-oportunidades/{id}/classification', [OpportunityClassificationController::class, 'index'])->whereNumber('id');
@@ -261,6 +265,9 @@ Route::middleware(['auth', 'verified', EnsureScopedPortalAdministration::class])
Route::get('opportunities/create', [OpportunityManagementController::class, 'edit']);
Route::get('opportunities/{id}/edit', [OpportunityManagementController::class, 'edit'])->whereNumber('id');
Route::get('opportunities/{id}/view', [OpportunityManagementController::class, 'edit'])->whereNumber('id');
Route::get('opportunities/{id}/destaque', [OpportunityCurationController::class, 'show'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
Route::post('opportunities/{id}/destaque/previa', [OpportunityCurationController::class, 'preview'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
Route::post('opportunities/{id}/destaque/confirmar', [OpportunityCurationController::class, 'confirm'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
Route::post('opportunities/drafts', [OpportunityManagementController::class, 'save']);
Route::put('opportunities/{id}/draft', [OpportunityManagementController::class, 'save'])->whereNumber('id');
Route::post('opportunities/{id}/lifecycle', [OpportunityManagementController::class, 'lifecycle'])->whereNumber('id');
@@ -0,0 +1,85 @@
<?php
namespace Tests\Unit;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityCuration;
use App\Services\OpportunityManagement;
use App\Services\OpportunityProfileWorkflow;
use App\Services\OpportunityWorkflowTasks;
use App\Services\PortalProfileAccess;
use Illuminate\Foundation\Testing\TestCase;
use Illuminate\Support\Facades\DB;
use Mockery;
use Symfony\Component\HttpKernel\Exception\HttpException;
class OpportunityCurationAuthorityTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
DB::shouldReceive('connection')->never();
}
public function test_legacy_curation_cannot_restore_a_revoked_or_replaced_legacy_authority(): void
{
$actor = new User;
$actor->id = 17;
$record = new Opportunity;
$record->owner_user_id = null;
$access = Mockery::mock(PortalProfileAccess::class);
$access->shouldReceive('canView')->once()->with($actor, $record)->andReturnTrue();
$access->shouldReceive('legacyWriteAllowed')->once()->with($actor)->andReturnFalse();
$management = Mockery::mock(OpportunityManagement::class);
$management->shouldReceive('installed')->once()->andReturnTrue();
$this->assertFalse((new OpportunityCuration($access, $management))->allowed($actor, $record));
}
public function test_native_assignment_to_another_publisher_is_not_inferred_away(): void
{
$actor = new User;
$actor->id = 17;
$record = new Opportunity;
$record->id = 1;
$record->owner_user_id = 19;
$record->organization_id = null;
$record->opportunity_category = 'investment_project';
$record->workflow_context = ['approval' => ['actor_id' => 20], 'publication_assignment' => ['assignee_user_id' => 21]];
$access = Mockery::mock(PortalProfileAccess::class);
$access->shouldReceive('canView')->once()->with($actor, $record)->andReturnTrue();
$access->shouldReceive('scoped')->once()->with($actor, 'publisher', null, 'investment_project')->andReturnTrue();
$access->shouldReceive('operational')->once()->with($actor, 'publish', 1)->andReturnTrue();
$access->shouldReceive('conflict')->once()->with($actor, $record)->andReturnFalse();
$management = Mockery::mock(OpportunityManagement::class);
$management->shouldReceive('installed')->once()->andReturnTrue();
$this->assertFalse((new OpportunityCuration($access, $management))->allowed($actor, $record));
}
public function test_native_editorial_authorisation_reuses_the_existing_fresh_mfa_requirement(): void
{
$actor = new User;
$actor->id = 17;
$record = new Opportunity;
$record->id = 1;
$record->owner_user_id = 19;
$record->organization_id = null;
$record->opportunity_category = 'investment_project';
$access = Mockery::mock(PortalProfileAccess::class);
$access->shouldReceive('authorize')->once()->with($actor, 'view', $record);
$access->shouldReceive('promoterRecordScope')->once()->with($actor, $record)->andReturnFalse();
$access->shouldReceive('scoped')->once()->with($actor, 'publisher', null, 'investment_project')->andReturnTrue();
$access->shouldReceive('operational')->once()->with($actor, 'publish', 1)->andReturnTrue();
$access->shouldReceive('conflict')->once()->with($actor, $record)->andReturnFalse();
$access->shouldReceive('mfa')->once()->with($actor)->andReturnFalse();
$flow = new OpportunityProfileWorkflow($access, Mockery::mock(OpportunityManagement::class), Mockery::mock(OpportunityCanonicalContract::class), Mockery::mock(OpportunityWorkflowTasks::class));
try {
$flow->authorizeEditorialCuration($actor, $record);
$this->fail('A stale MFA session was accepted.');
} catch (HttpException $error) {
$this->assertSame(403, $error->getStatusCode());
$this->assertSame('required', $error->getHeaders()['X-Portal-Reauthentication']);
}
}
}
@@ -0,0 +1,133 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { compileFunction } from 'node:vm';
import { createRequire } from 'node:module';
import ts from 'typescript';
const require = createRequire(import.meta.url);
function nodes(tree) {
if (Array.isArray(tree)) return tree.flatMap(nodes);
if (!tree || typeof tree !== 'object') return [];
return [tree, ...nodes(tree.props?.children)];
}
function text(tree) {
if (typeof tree === 'string') return tree;
if (Array.isArray(tree)) return tree.map(text).join('');
return tree?.props ? text(tree.props.children) : '';
}
function harness() {
const memory = [], effects = [], calls = [], reloads = [];
let cursor = 0, pending = [], dirty = false, serial = 0, handler;
let props = { record: { id: 1, title: 'QA first', featured: false }, etag: 'a'.repeat(64), history: { data: [], current_page: 1 }, basePath: '/management/opportunities' };
const code = ts.transpileModule(fs.readFileSync(new URL('../../resources/js/pages/management/opportunity-curation.tsx', import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2022 },
}).outputText;
const module = { exports: {} };
const mocks = {
react: {
useState(initial) {
const i = cursor++;
if (!(i in memory)) memory[i] = typeof initial === 'function' ? initial() : initial;
return [memory[i], (value) => {
const next = typeof value === 'function' ? value(memory[i]) : value;
if (!Object.is(next, memory[i])) { memory[i] = next; dirty = true; }
}];
},
useRef(initial) { const i = cursor++; memory[i] ??= { current: initial }; return memory[i]; },
useEffect(effect, dependencies) {
const i = cursor++;
if (!effects[i] || dependencies.some((value, index) => value !== effects[i][index])) {
effects[i] = dependencies; pending.push(effect);
}
},
},
'@inertiajs/react': { Link: 'a', router: { reload: (options) => reloads.push(options) } },
'@/lib/operation-key': { createOperationKey: () => `aaaaaaaa-aaaa-4aaa-8aaa-${String(++serial).padStart(12, '0')}` },
'@/lib/profile-json': {
ProfileValidationError: class extends Error {},
profileJson: (url, data) => { calls.push({ url, data: structuredClone(data) }); return handler(url, data); },
},
'./shared': { Management: 'main', button: '', control: '' },
};
compileFunction(code, ['require', 'module', 'exports'])((name) => mocks[name] ?? require(name), module, module.exports);
let tree;
function render() {
do { dirty = false; cursor = 0; pending = []; tree = module.exports.default(props); for (const effect of pending) effect(); } while (dirty);
return tree;
}
return {
calls, reloads, render,
setHandler(fn) { handler = fn; },
setProps(next) { props = next; return render(); },
button(label) { return nodes(render()).find(node => node.type === 'button' && text(node) === label); },
input(type) { return nodes(render()).find(node => node.type === type); },
get props() { return props; },
};
}
test('editing rationale invalidates both the preview and its confirmation without submitting', async () => {
const h = harness();
h.setHandler(async () => ({ hash: 'b'.repeat(64), title: 'QA first', before: false, featured: true, reason: 'Rationale for QA editorial selection' }));
h.input('textarea').props.onChange({ target: { value: 'Rationale for QA editorial selection' } });
await h.button('Conferir prévia').props.onClick();
assert.equal(h.button('Confirmar decisão de destaque').props.disabled, true);
h.input('input').props.onChange({ target: { checked: true } });
assert.equal(h.button('Confirmar decisão de destaque').props.disabled, false);
h.input('textarea').props.onChange({ target: { value: 'Changed editorial rationale must be reviewed again' } });
assert.equal(h.button('Confirmar decisão de destaque'), undefined);
assert.equal(h.calls.length, 1);
assert.equal(h.calls[0].data.featured, true);
});
test('in-flight preview is single; uncertain confirmation keeps the same intent for recovery', async () => {
const h = harness();
let release;
h.setHandler(() => new Promise(resolve => { release = resolve; }));
h.input('textarea').props.onChange({ target: { value: 'Rationale for QA editorial selection' } });
const first = h.button('Conferir prévia').props.onClick();
await h.button('Conferir prévia').props.onClick();
assert.equal(h.calls.length, 1);
assert.equal(h.input('fieldset').props.disabled, true);
release({ hash: 'b'.repeat(64), title: 'QA first', before: false, featured: true, reason: 'Rationale for QA editorial selection' });
await first;
h.input('input').props.onChange({ target: { checked: true } });
h.setHandler(async () => { throw new Error('Response lost'); });
await h.button('Confirmar decisão de destaque').props.onClick();
const uncertain = h.calls.at(-1).data;
h.setHandler(async () => ({ replayed: true, featured: true, etag: 'c'.repeat(64) }));
await h.button('Confirmar decisão de destaque').props.onClick();
assert.deepEqual(h.calls.at(-1).data, uncertain);
assert.equal(h.reloads.length, 1);
assert.deepEqual(h.reloads[0].only, ['record', 'etag', 'history']);
assert.match(text(h.render()), /Decisão já registada/);
});
test('a delayed preview cannot be adopted after another record and version arrive', async () => {
const h = harness();
let release;
h.setHandler(() => new Promise(resolve => { release = resolve; }));
h.input('textarea').props.onChange({ target: { value: 'Rationale for QA editorial selection' } });
const request = h.button('Conferir prévia').props.onClick();
h.setProps({ ...h.props, record: { id: 2, title: 'QA second', featured: false }, etag: 'd'.repeat(64) });
release({ hash: 'b'.repeat(64), title: 'QA first', before: false, featured: true, reason: 'Old rationale' });
await request;
assert.equal(h.button('Confirmar decisão de destaque'), undefined);
assert.match(text(h.render()), /QA second/);
assert.doesNotMatch(text(h.render()), /Old rationale/);
assert.equal(h.input('textarea').props.value, '');
});
test('changing only the history page retains the unsubmitted rationale and reviewed decision', async () => {
const h = harness();
h.setHandler(async () => ({ hash: 'b'.repeat(64), title: 'QA first', before: false, featured: true, reason: 'Reviewed QA rationale retained during paging' }));
h.input('textarea').props.onChange({ target: { value: 'Reviewed QA rationale retained during paging' } });
await h.button('Conferir prévia').props.onClick();
h.input('input').props.onChange({ target: { checked: true } });
h.setProps({ ...h.props, history: { data: [], current_page: 2, prev_page_url: '/management/opportunities/1/destaque?page=1' } });
assert.equal(h.input('textarea').props.value, 'Reviewed QA rationale retained during paging');
assert.equal(h.button('Confirmar decisão de destaque').props.disabled, false);
const link = nodes(h.render()).find(node => node.type === 'a' && text(node) === 'Anterior');
assert.equal(link.props.preserveState, true);
assert.equal(link.props.preserveScroll, true);
});