feat(offers): implement phase four private details and confidentiality in QA
This commit is contained in:
@@ -6,15 +6,84 @@ use App\Models\Opportunity;
|
||||
use App\Services\OpportunityCanonicalContract;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityProfileWorkflow;
|
||||
use App\Services\OpportunityResources;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\PrivateOfferConfidentiality;
|
||||
use App\Services\PrivateOfferPreparation;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
|
||||
final class PrivateOfferPreparationController extends Controller
|
||||
{
|
||||
public function resources(Request $request, PrivateOfferPreparation $service, int $id): JsonResponse
|
||||
{
|
||||
$record = $service->record($request->user(), $id);
|
||||
$resources = [];
|
||||
foreach (['images', 'documents'] as $kind) {
|
||||
$resources[$kind] = array_map(fn ($row) => ['id' => $row->id, 'version' => $row->version, 'deleted' => $row->deleted_at !== null,
|
||||
'caption' => $row->payload['caption'] ?? null, 'digitally_edited' => $row->payload['digitally_edited'] ?? null,
|
||||
'edit_description' => $row->payload['edit_description'] ?? null,
|
||||
'scan' => $row->file->malware_scan_status ?? 'pending'], app(OpportunityResources::class)->listing($id, $kind));
|
||||
}
|
||||
|
||||
return response()->json(['resources' => $resources, 'etag' => app(OpportunityManagement::class)->etag($record),
|
||||
'internalProofAccess' => app(PrivateOfferConfidentiality::class)->internal($request->user(), $record)]);
|
||||
}
|
||||
|
||||
public function attachment(Request $request, PrivateOfferPreparation $service, int $id, string $kind, string $resourceId): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$record = $service->record($request->user(), $id);
|
||||
abort_unless(in_array($kind, ['documents', 'images'], true), 404);
|
||||
$link = DB::table('opportunity_'.$kind)->where('opportunity_id', $id)->where('id', $resourceId)->whereNull('deleted_at')->first();
|
||||
abort_unless($link !== null, 404);
|
||||
$document = DB::table('documents')->where('id', $link->document_id)->whereNull('deleted_at')->first();
|
||||
abort_unless($document !== null, 404);
|
||||
|
||||
return app(VerifiedDocumentBinary::class)->respond($document);
|
||||
}
|
||||
|
||||
public function confidentiality(Request $request, PrivateOfferConfidentiality $privacy, int $id): JsonResponse|Response
|
||||
{
|
||||
$overview = $privacy->overview($request->user(), $id);
|
||||
|
||||
return $request->expectsJson() ? response()->json($overview) : Inertia::render('offers/confidentiality', ['offerId' => $id]);
|
||||
}
|
||||
|
||||
public function confidentialityAction(Request $request, PrivateOfferConfidentiality $privacy, int $id): JsonResponse
|
||||
{
|
||||
return response()->json($privacy->act($request->user(), $id, $request->all()));
|
||||
}
|
||||
|
||||
public function confidentialRead(Request $request, PrivateOfferConfidentiality $privacy, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $privacy->read($request->user(), $id);
|
||||
} catch (HttpExceptionInterface $error) {
|
||||
app(PortalProfileAccess::class)->audit($request->user(), 'offer_confidential_denied', ['record_id' => $id, 'http_status' => $error->getStatusCode()]);
|
||||
throw $error;
|
||||
}
|
||||
abort_if(isset($result['denied']), 403, 'É necessária aceitação do NDA e autorização expressa do vendedor para esta versão.');
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function nda(Request $request, PrivateOfferConfidentiality $privacy, int $id): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$overview = $privacy->overview($request->user(), $id);
|
||||
abort_unless($overview['nda'], 409, 'O NDA aprovado ainda não foi disponibilizado.');
|
||||
app(PortalProfileAccess::class)->audit($request->user(), 'offer_nda_access', ['record_id' => $id, 'version_hash' => $overview['version_hash'], 'nda' => $overview['nda']]);
|
||||
|
||||
$document = DB::table('documents')->where('id', $overview['nda']['document_id'])->firstOrFail();
|
||||
abort_unless((int) $document->version_no === $overview['nda']['version'] && hash_equals(trim($document->sha256), $overview['nda']['sha256']), 409, 'O exemplar do NDA mudou; actualize antes de continuar.');
|
||||
|
||||
return app(VerifiedDocumentBinary::class)->respond($document);
|
||||
}
|
||||
|
||||
public function index(Request $request, PortalProfileAccess $access): Response
|
||||
{
|
||||
$filters = $request->validate(['state' => 'nullable|in:draft,submitted,in_review,needs_information,rejected,approved']);
|
||||
@@ -40,7 +109,9 @@ final class PrivateOfferPreparationController extends Controller
|
||||
$options = app(OpportunityCanonicalContract::class)->options($request->user(), $record);
|
||||
|
||||
return Inertia::render('offers/preparation', ['record' => $presentation, 'contexts' => $contexts,
|
||||
'contacts' => $options['contacts'], 'contactInvestors' => $options['contactInvestors']]);
|
||||
'contacts' => $options['contacts'], 'contactInvestors' => $options['contactInvestors'],
|
||||
'sectors' => DB::table('sectors')->where('is_active', true)->orderBy('name')->get(['id', 'name']),
|
||||
'propertyTypes' => array_values(array_filter($options['definitions']['realEstatePatch']['properties']['property_type']['enum'], fn ($type) => is_string($type)))]);
|
||||
}
|
||||
|
||||
public function save(Request $request, PrivateOfferPreparation $service, ?int $id = null): JsonResponse
|
||||
|
||||
@@ -45,6 +45,7 @@ class OpportunityLibrarySupport
|
||||
*/
|
||||
public function attach(User $actor, Opportunity $opportunity, array $input): \stdClass
|
||||
{
|
||||
abort_if($opportunity->isPrivateOfferPreparation(), 422, 'Os comprovativos de Ofertas usam originais reservados à validação interna.');
|
||||
app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity);
|
||||
$data = Validator::make($input, ['resource_id' => 'required|integer|min:1', 'creation_key' => 'required|uuid', 'etag' => 'required|string|size:64', 'reason' => 'required|string|min:20|max:1000', 'section' => 'nullable|string|max:255', 'supported_statement' => 'nullable|string|max:2000'])->validate();
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ class OpportunityResources
|
||||
}
|
||||
abort_if(! $id && DB::table($table)->where('id', $resourceId)->exists(), 409, 'Esta criação já foi registada; recupere o recurso existente.');
|
||||
$active = DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at');
|
||||
abort_if(! $id && $active->count() >= ($kind === 'locations' ? 50 : ($kind === 'images' ? 20 : 100)), 422, 'Limite de recursos atingido.');
|
||||
abort_if(! $id && $active->count() >= $this->limit($opportunity, $kind), 422, 'Limite de recursos atingido.');
|
||||
if ($kind === 'locations') {
|
||||
$others = (clone $active)->when($id, fn ($q) => $q->where('id', '!=', $id))->get();
|
||||
foreach ($others as $other) {
|
||||
@@ -172,7 +172,7 @@ class OpportunityResources
|
||||
abort_if(($data['action'] === 'delete') === ($record->deleted_at !== null), 409, 'Estado já alterado.');
|
||||
$payload = json_decode($record->payload, true);
|
||||
if ($data['action'] === 'restore') {
|
||||
abort_if(DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->count() >= ($kind === 'locations' ? 50 : ($kind === 'images' ? 20 : 100)), 422, 'Limite de recursos ativos atingido; reconcilie antes de restaurar.');
|
||||
abort_if(DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->count() >= $this->limit($opportunity, $kind), 422, 'Limite de recursos ativos atingido; reconcilie antes de restaurar.');
|
||||
$this->validatePayload($kind, $payload, $opportunityId);
|
||||
// Restoration never silently reclaims a primary/cover/order occupied by another resource.
|
||||
if ($kind === 'images') {
|
||||
@@ -221,12 +221,22 @@ class OpportunityResources
|
||||
*/
|
||||
private function validatePayload(string $kind, array $payload, int $opportunityId): array
|
||||
{
|
||||
$offer = Opportunity::findOrFail($opportunityId);
|
||||
if ($offer->isPrivateOfferPreparation()) {
|
||||
Validator::make($payload, ['visibility' => 'required|in:internal',
|
||||
...($kind === 'images' ? ['digitally_edited' => 'required|boolean', 'edit_description' => 'nullable|required_if:digitally_edited,true|string|max:1000'] : []),
|
||||
...($kind === 'documents' ? ['category' => 'required|in:real_estate_proof', 'classification' => 'required|in:restricted'] : [])])->validate();
|
||||
abort_if($kind === 'documents' && $offer->opportunity_category !== 'real_estate', 422, 'Este percurso aceita comprovativos imobiliários.');
|
||||
}
|
||||
$rules = ['title' => 'nullable|string|max:120', 'source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'visibility' => 'nullable|in:internal,authenticated,public'];
|
||||
if ($kind === 'locations') {
|
||||
$rules += ['country_code' => ['nullable', app(CountryCatalogue::class)->rule()]];
|
||||
$rules += ['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid', 'role' => 'nullable|in:headquarters,production,raw_material,storage,logistics,market,support,other', 'role_description' => 'nullable|string|max:1000', 'operational_unit' => 'nullable|string|max:120', 'is_primary' => 'nullable|boolean', 'address' => 'nullable|string|max:300', 'geometry' => 'nullable|array', 'area_value' => 'nullable|numeric|gt:0', 'area_unit' => ['nullable', Rule::in(self::AREA_UNITS)], 'allocation_percentage' => 'nullable|numeric|between:0,100', 'territorial_purpose' => 'nullable|in:implementation,context,impact,coverage', 'multi_district_rationale' => 'nullable|string|max:1000'];
|
||||
} elseif ($kind === 'images') {
|
||||
$rules += ['role' => 'nullable|in:cover,gallery,location,asset,product,plan,rendering,evidence,other', 'role_description' => 'nullable|string|max:1000', 'is_cover' => 'nullable|boolean', 'caption' => 'nullable|string|max:300', 'alt_text' => 'nullable|string|max:250', 'location_id' => ['nullable', 'uuid', Rule::exists('opportunity_location_drafts', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'captured_at' => 'nullable|date_format:Y-m-d|before_or_equal:today', 'author_source' => 'nullable|string|max:150', 'rights_status' => 'nullable|in:owned,licensed,authorized,public_domain', 'rights_document_id' => ['nullable', 'uuid', Rule::exists('opportunity_documents', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'rights_valid_until' => 'nullable|date_format:Y-m-d', 'sort_order' => 'nullable|integer|min:0'];
|
||||
if ($offer->isPrivateOfferPreparation()) {
|
||||
$rules += ['digitally_edited' => 'boolean', 'edit_description' => 'nullable|string|max:1000'];
|
||||
}
|
||||
} else {
|
||||
$rules += ['category' => 'nullable|string|max:100', 'issuer' => 'nullable|string|max:255', 'edition' => 'nullable|string|max:120', 'valid_until' => 'nullable|date_format:Y-m-d', 'classification' => 'nullable|in:public,internal,confidential,restricted', 'section' => 'nullable|string|max:255', 'supported_statement' => 'nullable|string|max:2000'];
|
||||
}
|
||||
@@ -257,6 +267,11 @@ class OpportunityResources
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function limit(Opportunity $record, string $kind): int
|
||||
{
|
||||
return $kind === 'locations' ? 50 : ($kind === 'images' ? ($record->isPrivateOfferPreparation() ? 6 : 20) : 100);
|
||||
}
|
||||
|
||||
/** @return array{string,string} */
|
||||
private function store(string $kind, UploadedFile $file): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<?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\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
|
||||
/** The same opportunity owns consent, grants and history. No automatic publication. */
|
||||
final class PrivateOfferConfidentiality
|
||||
{
|
||||
public function internal(User $actor, Opportunity $record): bool
|
||||
{
|
||||
$access = app(PortalProfileAccess::class);
|
||||
|
||||
return $access->canView($actor, $record) && $access->mfa($actor) && ! $access->has($actor, 'auditor') && ! $access->conflict($actor, $record)
|
||||
&& $access->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category);
|
||||
}
|
||||
|
||||
public function seller(User $actor, Opportunity $record): bool
|
||||
{
|
||||
$access = app(PortalProfileAccess::class);
|
||||
|
||||
return (int) $record->owner_user_id === $actor->id && $access->promoterRecordScope($actor, $record)
|
||||
&& $access->canCreate($actor, $record->organization_id, $record->opportunity_category) && $access->operational($actor, 'edit', $record->id);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function nda(): ?array
|
||||
{
|
||||
$definition = config('private_offers.nda');
|
||||
if (! is_array($definition) || empty($definition['approval_reference']) || empty($definition['document_id']) || empty($definition['version']) || empty($definition['sha256'])) {
|
||||
return null;
|
||||
}
|
||||
$doc = DB::table('documents')->where('id', $definition['document_id'])->whereNull('deleted_at')->first();
|
||||
if (! $doc || $doc->status !== 'active' || $doc->malware_scan_status !== 'clean' || (int) $doc->version_no !== (int) $definition['version']
|
||||
|| ! hash_equals(trim($doc->sha256), $definition['sha256']) || ! hash_equals(trim($doc->sha256), trim((string) $doc->malware_scanned_sha256))) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
app(VerifiedDocumentBinary::class)->respond($doc);
|
||||
} catch (HttpExceptionInterface $error) {
|
||||
return null;
|
||||
}
|
||||
$result = ['document_id' => $doc->id, 'version' => (int) $doc->version_no, 'sha256' => trim($doc->sha256), 'approval_reference' => $definition['approval_reference']];
|
||||
|
||||
return [...$result, 'fingerprint' => hash('sha256', json_encode($result, JSON_THROW_ON_ERROR))];
|
||||
}
|
||||
|
||||
private function company(int $id, bool $lock = false): Opportunity
|
||||
{
|
||||
$record = Opportunity::query()->when($lock, fn ($q) => $q->lockForUpdate())->findOrFail($id);
|
||||
abort_unless($record->isPrivateOfferPreparation() && $record->opportunity_category === 'business_sale', 404);
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function approvedHash(Opportunity $record): string
|
||||
{
|
||||
$hash = $record->workflow_context['approval']['hash'] ?? null;
|
||||
abort_unless(is_string($hash) && app(PortalProfileAccess::class)->state($record) === 'approved', 409, 'Esta versão não tem aprovação editorial actual.');
|
||||
try {
|
||||
$snapshot = app(PrivateOfferPreparation::class)->snapshot($record);
|
||||
} catch (ValidationException $error) {
|
||||
abort(409, 'A versão ou os suportes exigem reconferência editorial.');
|
||||
}
|
||||
abort_unless(hash_equals($hash, $snapshot['hash']), 409, 'A versão mudou; é necessária reconferência editorial.');
|
||||
|
||||
return $hash;
|
||||
}
|
||||
|
||||
/** PostgreSQL jsonb reorders object keys; identity is the version and checksum, not array order.
|
||||
* @param array<string,mixed> $nda */
|
||||
private function sameNda(mixed $accepted, array $nda): bool
|
||||
{
|
||||
if (! is_array($accepted)) {
|
||||
return false;
|
||||
}
|
||||
foreach (['document_id', 'version', 'sha256', 'approval_reference', 'fingerprint'] as $key) {
|
||||
if (($accepted[$key] ?? null) !== $nda[$key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function overview(User $actor, int $id): array
|
||||
{
|
||||
$record = $this->company($id);
|
||||
// An interested party learns no private title, contacts, documents or financial values.
|
||||
$seller = $this->seller($actor, $record);
|
||||
$nda = $this->nda();
|
||||
$context = $record->workflow_context['offer_confidentiality'] ?? [];
|
||||
$currentHash = null;
|
||||
try {
|
||||
$currentHash = $this->approvedHash($record);
|
||||
} catch (HttpExceptionInterface $error) {
|
||||
// A previous acceptance remains withdrawable even if the offer was revised.
|
||||
abort_unless($seller || isset($context['acceptances'][(string) $actor->id]), 404);
|
||||
}
|
||||
$history = array_values(array_filter($context['events'] ?? [], fn ($event) => $seller || $event['interested_user_id'] === $actor->id));
|
||||
|
||||
return ['offer_id' => $id, 'nda' => $nda, 'nda_pending' => $nda === null, 'version_hash' => $record->workflow_context['approval']['hash'] ?? null,
|
||||
'etag' => $seller ? app(OpportunityManagement::class)->etag($record) : null,
|
||||
'can_accept' => ! $seller && $nda !== null && $currentHash !== null && ! app(PortalProfileAccess::class)->conflict($actor, $record),
|
||||
'can_authorize' => $seller && $nda !== null && $currentHash !== null,
|
||||
'seller' => $seller, 'requests' => $seller ? array_values($context['acceptances'] ?? []) : [],
|
||||
'grants' => $seller ? array_values($context['grants'] ?? []) : [],
|
||||
'history' => array_map(fn ($event) => array_intersect_key($event, array_flip(['id', 'action', 'actor_id', 'at', 'version_hash', 'nda'])), $history),
|
||||
'acceptance' => $context['acceptances'][(string) $actor->id] ?? null];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input
|
||||
* @return array<string,mixed> */
|
||||
public function act(User $actor, int $id, array $input): array
|
||||
{
|
||||
$data = validator($input, ['action' => 'required|in:accept,authorize,revoke,withdraw', 'version_hash' => 'required|string|size:64',
|
||||
'nda_sha256' => 'required_unless:action,revoke,withdraw|string|size:64', 'acceptance_id' => 'required_if:action,authorize|uuid',
|
||||
'nda_fingerprint' => 'required_if:action,accept,authorize|string|size:64',
|
||||
'interested_user_id' => 'required_if:action,authorize,revoke|integer|min:1', 'confirm' => 'required|accepted',
|
||||
'reason' => ['nullable', 'required_if:action,authorize,revoke', 'string', 'max:1000', ...(in_array($input['action'] ?? null, ['authorize', 'revoke'], true) ? ['min:20'] : [])]])->validate();
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $data) {
|
||||
$record = $this->company($id, true);
|
||||
$context = $record->workflow_context;
|
||||
$privacy = $context['offer_confidentiality'] ?? ['acceptances' => [], 'grants' => [], 'events' => []];
|
||||
$action = $data['action'];
|
||||
$target = in_array($action, ['authorize', 'revoke'], true) ? (string) $data['interested_user_id'] : (string) $actor->id;
|
||||
$before = ['acceptance' => $privacy['acceptances'][$target] ?? null, 'grant' => $privacy['grants'][$target] ?? null];
|
||||
if (in_array($action, ['authorize', 'revoke'], true)) {
|
||||
abort_unless($this->seller($actor, $record), 403);
|
||||
} elseif ($action === 'accept') {
|
||||
abort_if($this->seller($actor, $record) || app(PortalProfileAccess::class)->conflict($actor, $record), 403);
|
||||
}
|
||||
if (in_array($action, ['accept', 'authorize'], true)) {
|
||||
$nda = $this->nda();
|
||||
abort_unless($nda !== null, 409, 'O exemplar aprovado do NDA ainda não foi disponibilizado.');
|
||||
abort_unless(hash_equals($this->approvedHash($record), $data['version_hash']) && hash_equals($nda['sha256'], $data['nda_sha256'])
|
||||
&& hash_equals($nda['fingerprint'], $data['nda_fingerprint']), 409);
|
||||
$subject = User::findOrFail((int) $target);
|
||||
abort_unless($subject->hasVerifiedEmail() && ! app(PortalProfileAccess::class)->conflict($subject, $record), 403);
|
||||
if ($action === 'accept') {
|
||||
$privacy['acceptances'][$target] = ['id' => (string) Str::uuid(), 'actor_id' => $actor->id, 'at' => now()->toIso8601String(),
|
||||
'version_hash' => $data['version_hash'], 'nda' => $nda, 'access_revision' => (int) $subject->portal_access_revision];
|
||||
unset($privacy['grants'][$target]); // A fresh acceptance never reuses a previous seller decision.
|
||||
} else {
|
||||
$acceptance = $privacy['acceptances'][$target] ?? null;
|
||||
abort_unless($acceptance && $acceptance['id'] === $data['acceptance_id'] && $acceptance['version_hash'] === $data['version_hash']
|
||||
&& $this->sameNda($acceptance['nda'], $nda) && $acceptance['access_revision'] === (int) $subject->portal_access_revision, 409);
|
||||
$privacy['grants'][$target] = ['interested_user_id' => (int) $target, 'seller_id' => $actor->id, 'at' => now()->toIso8601String(),
|
||||
'version_hash' => $data['version_hash'], 'acceptance_id' => $acceptance['id'], 'nda' => $nda];
|
||||
}
|
||||
} else {
|
||||
$previous = $privacy[$action === 'withdraw' ? 'acceptances' : 'grants'][$target] ?? null;
|
||||
abort_unless($previous && $previous['version_hash'] === $data['version_hash'], 409);
|
||||
unset($privacy['grants'][$target]);
|
||||
if ($action === 'withdraw') {
|
||||
unset($privacy['acceptances'][$target]);
|
||||
}
|
||||
}
|
||||
$event = ['id' => (string) Str::uuid(), 'action' => $action, 'actor_id' => $actor->id, 'interested_user_id' => (int) $target,
|
||||
'version_hash' => $data['version_hash'], 'at' => now()->toIso8601String(), 'nda_sha256' => $data['nda_sha256'] ?? null,
|
||||
'nda' => $nda ?? ($before['acceptance']['nda'] ?? $before['grant']['nda'] ?? null)];
|
||||
$privacy['events'][] = [...$event, 'reason' => Crypt::encryptString($data['reason'] ?? ''), 'before' => $before,
|
||||
'after' => ['acceptance' => $privacy['acceptances'][$target] ?? null, 'grant' => $privacy['grants'][$target] ?? null]];
|
||||
$context['offer_confidentiality'] = $privacy;
|
||||
$record->workflow_context = $context;
|
||||
$record->management_version++;
|
||||
$record->save();
|
||||
app(PortalProfileAccess::class)->audit($actor, 'offer_confidentiality_'.$action, ['record_id' => $id, ...$event]);
|
||||
|
||||
return ['recorded' => true, 'event_id' => $event['id']];
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function read(User $actor, int $id): array
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $id) {
|
||||
$record = $this->company($id, true);
|
||||
$nda = $this->nda();
|
||||
$privacy = $record->workflow_context['offer_confidentiality'] ?? [];
|
||||
$accept = $privacy['acceptances'][(string) $actor->id] ?? null;
|
||||
$grant = $privacy['grants'][(string) $actor->id] ?? null;
|
||||
$hash = $this->approvedHash($record);
|
||||
$seller = User::find($record->owner_user_id);
|
||||
$subject = User::find($actor->id);
|
||||
$allowed = $nda && $accept && $grant && $seller && $subject && $this->seller($seller, $record) && $subject->hasVerifiedEmail()
|
||||
&& $accept['access_revision'] === (int) $subject->portal_access_revision && $grant['seller_id'] === $seller->id
|
||||
&& $grant['acceptance_id'] === $accept['id'] && $grant['version_hash'] === $hash && $accept['version_hash'] === $hash
|
||||
&& $this->sameNda($grant['nda'], $nda) && $this->sameNda($accept['nda'], $nda) && ! app(PortalProfileAccess::class)->conflict($actor, $record);
|
||||
app(PortalProfileAccess::class)->audit($actor, $allowed ? 'offer_confidential_access' : 'offer_confidential_denied', ['record_id' => $id, 'version_hash' => $hash,
|
||||
'nda' => $nda, 'acceptance_id' => $accept['id'] ?? null]);
|
||||
// Return the refusal after transaction commit so the denied-access audit survives.
|
||||
if (! $allowed) {
|
||||
return ['denied' => true];
|
||||
}
|
||||
|
||||
$business = $record->workflow_context['submission']['payload']['private_offer']['business'] ?? [];
|
||||
foreach (['sector_id' => 'sector', 'secondary_sector_id' => 'secondary_sector'] as $field => $label) {
|
||||
if (isset($business[$field])) {
|
||||
$business[$label] = DB::table('sectors')->where('id', $business[$field])->value('name');
|
||||
}
|
||||
unset($business[$field]);
|
||||
}
|
||||
|
||||
return ['offer_id' => $id, 'version_hash' => $hash, 'business' => $business];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/** Optional commercial fields confirmed by Tempo for phase four; no title certification. */
|
||||
final class PrivateOfferDetails
|
||||
{
|
||||
/** @param array<string,mixed> $input
|
||||
* @return array<string,mixed> */
|
||||
public function validate(array $input): array
|
||||
{
|
||||
$bytes = file_get_contents(resource_path('data/investmoz-opportunity-modular.schema.json'));
|
||||
if ($bytes === false) {
|
||||
throw new \RuntimeException('Opportunity schema unavailable.');
|
||||
}
|
||||
$schema = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
|
||||
$property = $schema['$defs']['realEstatePatch']['properties'];
|
||||
|
||||
return Validator::make($input, [
|
||||
'business' => 'sometimes|nullable|array:sector_id,secondary_sector_id,revenue,cash_flow,currency,reason,availability',
|
||||
'business.sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)],
|
||||
'business.secondary_sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)],
|
||||
'business.revenue' => ['nullable', 'string', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
|
||||
'business.cash_flow' => ['nullable', 'string', 'regex:/^-?\d{1,16}(\.\d{1,2})?$/'],
|
||||
'business.currency' => 'nullable|required_with:business.revenue,business.cash_flow|in:MZN,USD',
|
||||
'business.reason' => 'nullable|string|max:1000', 'business.availability' => 'nullable|in:available,sold',
|
||||
'property' => 'sometimes|nullable|array:transaction_type,property_type,land_area,built_area,area_unit,bedrooms,bathrooms,amenities,availability',
|
||||
'property.transaction_type' => 'nullable|in:sale,lease',
|
||||
'property.property_type' => ['nullable', Rule::in($property['property_type']['enum'])],
|
||||
'property.land_area' => 'nullable|numeric|gt:0', 'property.built_area' => 'nullable|numeric|gt:0',
|
||||
'property.area_unit' => ['nullable', Rule::in(OpportunityResources::AREA_UNITS)],
|
||||
'property.bedrooms' => 'nullable|integer|min:0|max:10000', 'property.bathrooms' => 'nullable|integer|min:0|max:10000',
|
||||
'property.amenities' => 'nullable|array|max:100', 'property.amenities.*' => 'string|max:250',
|
||||
'property.availability' => 'nullable|in:available,sold,leased',
|
||||
])->validate();
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $offer */
|
||||
public function assertCategory(array $offer, string $category): void
|
||||
{
|
||||
Validator::make($offer, [
|
||||
'business' => $category === 'business_sale' ? 'nullable|array' : 'prohibited',
|
||||
'property' => $category === 'real_estate' ? 'nullable|array' : 'prohibited',
|
||||
])->validate();
|
||||
}
|
||||
|
||||
/** Snapshot all active linked originals and metadata in the existing editorial version.
|
||||
* @return array<string,array<string,mixed>> */
|
||||
public function resources(Opportunity $record): array
|
||||
{
|
||||
$result = ['documents' => [], 'images' => [], 'locations' => []];
|
||||
foreach (array_keys($result) as $kind) {
|
||||
foreach (app(OpportunityResources::class)->listing($record->id, $kind) as $row) {
|
||||
if ($row->deleted_at !== null) {
|
||||
continue;
|
||||
}
|
||||
if ($kind !== 'locations') {
|
||||
$file = $row->file;
|
||||
$doc = DB::table('documents')->where('id', $row->document_id)->first();
|
||||
if (! $doc || $doc->deleted_at !== null || $doc->status !== 'active' || $doc->malware_scan_status !== 'clean'
|
||||
|| ! hash_equals(trim($doc->sha256), trim((string) $doc->malware_scanned_sha256))) {
|
||||
throw ValidationException::withMessages(['attachments' => 'Aguarde uma análise de segurança válida dos anexos.']);
|
||||
}
|
||||
$result[$kind][$row->id] = ['version' => $row->version, 'document_id' => $row->document_id, 'payload' => $row->payload, 'file' => $file];
|
||||
} else {
|
||||
$result[$kind][$row->id] = ['version' => $row->version, 'payload' => $row->payload];
|
||||
}
|
||||
}
|
||||
ksort($result[$kind]);
|
||||
}
|
||||
if (count($result['images']) > 6) {
|
||||
throw ValidationException::withMessages(['images' => 'A oferta admite no máximo seis fotografias.']);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,17 @@ final class PrivateOfferPreparation
|
||||
/** @return array<string,mixed> */
|
||||
public function validate(mixed $input, bool $complete = false): array
|
||||
{
|
||||
return Validator::make(['private_offer' => $input], [
|
||||
'private_offer' => 'present|array:price_mode,amount,currency,contact_id',
|
||||
$validated = Validator::make(['private_offer' => $input], [
|
||||
'private_offer' => 'present|array:price_mode,amount,currency,contact_id,business,property',
|
||||
'private_offer.price_mode' => 'nullable|in:amount,negotiable',
|
||||
'private_offer.amount' => ['nullable', 'string', ...($complete ? ['required_if:private_offer.price_mode,amount'] : []), 'prohibited_unless:private_offer.price_mode,amount', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
|
||||
'private_offer.currency' => ['nullable', ...($complete ? ['required_if:private_offer.price_mode,amount'] : []), 'prohibited_unless:private_offer.price_mode,amount', 'in:MZN,USD'],
|
||||
'private_offer.contact_id' => 'nullable|uuid',
|
||||
'private_offer.business' => 'sometimes|nullable|array',
|
||||
'private_offer.property' => 'sometimes|nullable|array',
|
||||
])->validate()['private_offer'];
|
||||
|
||||
return array_replace($validated, app(PrivateOfferDetails::class)->validate(array_intersect_key($validated, array_flip(['business', 'property']))));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
@@ -40,6 +44,8 @@ final class PrivateOfferPreparation
|
||||
|
||||
return DB::transaction(function () use ($actor, $input, $id) {
|
||||
$record = app(OpportunityManagement::class)->save($actor, $input, $id, true);
|
||||
$values = app(OpportunityManagement::class)->values($record);
|
||||
app(PrivateOfferDetails::class)->assertCategory($values['private_offer'] ?? [], $values['opportunity_category']);
|
||||
$contactId = app(OpportunityManagement::class)->values($record)['private_offer']['contact_id'] ?? null;
|
||||
if ($contactId && ! app(OpportunityContacts::class)->related($record, $contactId)) {
|
||||
throw ValidationException::withMessages(['private_offer.contact_id' => 'Seleccione um contacto autorizado do promotor.']);
|
||||
@@ -128,6 +134,8 @@ final class PrivateOfferPreparation
|
||||
Validator::make($values, ['title' => 'required|string|max:150', 'description' => 'required|string|max:50000',
|
||||
'opportunity_category' => 'required', 'private_offer.price_mode' => 'required|in:amount,negotiable', 'private_offer.contact_id' => 'required|uuid'])->validate();
|
||||
$offer = $this->validate($values['private_offer'], true);
|
||||
app(PrivateOfferDetails::class)->assertCategory($offer, $values['opportunity_category']);
|
||||
$resources = app(PrivateOfferDetails::class)->resources($record);
|
||||
if (! app(OpportunityContacts::class)->related($record, $offer['contact_id'])) {
|
||||
throw ValidationException::withMessages(['private_offer.contact_id' => 'O contacto já não está disponível neste âmbito.']);
|
||||
}
|
||||
@@ -142,6 +150,10 @@ final class PrivateOfferPreparation
|
||||
$payload = ['title' => $values['title'], 'description' => $values['description'], 'opportunity_category' => $values['opportunity_category'],
|
||||
'owner_user_id' => $record->owner_user_id, 'organization_id' => $record->organization_id, 'private_offer' => $offer,
|
||||
'contact_fingerprint' => hash('sha256', json_encode($contact, JSON_THROW_ON_ERROR)), 'territory' => $path];
|
||||
$extended = isset($offer['business']) || isset($offer['property']) || array_filter($resources) !== [];
|
||||
if ($extended) {
|
||||
$payload['resources'] = $resources;
|
||||
}
|
||||
$normalize = function (mixed $value) use (&$normalize): mixed {
|
||||
if (is_object($value)) {
|
||||
$value = get_object_vars($value);
|
||||
@@ -157,8 +169,10 @@ final class PrivateOfferPreparation
|
||||
};
|
||||
$payload = $normalize($payload);
|
||||
|
||||
return ['payload' => $payload, 'hash' => hash('sha256', json_encode(['contract' => 'private-offer-editorial-v2', 'payload' => $payload], JSON_THROW_ON_ERROR)),
|
||||
'record_version' => $record->management_version, 'contract_version' => 'private-offer-editorial-v2', 'resources' => ['documents' => [], 'images' => [], 'locations' => []]];
|
||||
$contract = $extended ? 'private-offer-editorial-v3' : 'private-offer-editorial-v2';
|
||||
|
||||
return ['payload' => $payload, 'hash' => hash('sha256', json_encode(['contract' => $contract, 'payload' => $payload], JSON_THROW_ON_ERROR)),
|
||||
'record_version' => $record->management_version, 'contract_version' => $contract, 'resources' => $resources];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
|
||||
@@ -61,7 +61,11 @@ class VerifiedDocumentBinary
|
||||
if ($table === 'opportunity_images') {
|
||||
$query->orWhereRaw("payload->'derivatives' @> ?::jsonb", [json_encode([['document_id' => $document->id]], JSON_THROW_ON_ERROR)]);
|
||||
}
|
||||
$links = $links->concat($query->get(['opportunity_id', 'deleted_at', 'payload']));
|
||||
$rows = $query->get(['opportunity_id', 'deleted_at', 'payload']);
|
||||
foreach ($rows as $link) {
|
||||
$link->resource_kind = $table;
|
||||
}
|
||||
$links = $links->concat($rows);
|
||||
}
|
||||
$privateOrigin = in_array($document->source_system ?? null, ['opportunity-resources', 'opportunity-image-derivatives'], true);
|
||||
if (! $privateOrigin) {
|
||||
@@ -72,6 +76,15 @@ class VerifiedDocumentBinary
|
||||
}
|
||||
$actor = request()->user();
|
||||
abort_unless($actor instanceof User, 403, 'Anexo privado: é necessário acesso autorizado à oportunidade.');
|
||||
foreach ($links as $link) {
|
||||
$offer = Opportunity::withTrashed()->whereKey($link->opportunity_id)->first();
|
||||
if ($offer?->isPrivateOfferPreparation() && $link->resource_kind === 'opportunity_documents') {
|
||||
$allowed = $link->deleted_at === null && ! $offer->trashed() && app(PrivateOfferConfidentiality::class)->internal($actor, $offer);
|
||||
app(PortalProfileAccess::class)->audit($actor, $allowed ? 'offer_proof_access_authorized' : 'offer_proof_access_denied',
|
||||
['record_id' => $offer->id, 'document_id' => $document->id, 'record_version' => $offer->management_version, 'sha256' => trim($document->sha256)]);
|
||||
abort_unless($allowed, 403, 'Comprovativo reservado à validação interna autorizada.');
|
||||
}
|
||||
}
|
||||
foreach ($links as $link) {
|
||||
$opportunity = Opportunity::whereKey($link->opportunity_id)->first();
|
||||
if ($link->deleted_at === null && $opportunity && $opportunity->status !== 'archived'
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// No legal text or operational NDA is supplied/approved. Never infer it from an upload.
|
||||
'nda' => null,
|
||||
];
|
||||
@@ -10173,3 +10173,15 @@ A publicação efectiva permanece bloqueada, incluindo rotas alternativas. O cat
|
||||
QA final: 10 testes/133 asserções aprovados; inclui acessos directos, estados inválidos, isolamento, preservação, hierarquia, decimais/moedas, mapa sem localização exacta, CSV, HTML compilado e regressão nativa autocontida. Passagem anterior: 36/41 aprovados; cinco casos nativos impedidos por referência Inhambane ausente na base sintética. Tipos, lint focado, Pint, PHPStan e build aprovados. Formatação global: os mesmos 61 ficheiros exteriores ao delta. Conferência visual/móvel pendente: `The browser access host is unavailable`; não substituída por aprovação técnica. Email real F2 mantém pendência.
|
||||
|
||||
Estado: implementado em QA com barreira fechada, não instalado; módulo integral e Procuras parciais. Limites representados: 1.000 candidatos e contexto de 30 territórios, assinalados quando truncados. Relatório: `docs/investmoz/v2/auditoria-portal/ofertas-fase3-entrega-qa-20261114.md`. Recibo: `outputs/offers-public-phase3-20261114/qa-result.json`. Próximo passo: conferência desktop/móvel e apreciação da entrega QA; sem instalação ou avanço de fase por inferência.
|
||||
|
||||
## 14/11/2026 — Ofertas Fase 4, particularidades e confidencialidade em QA
|
||||
|
||||
Base F3 7691cab3 preservada, tal como F1/F2 e plano aceite. A Tempo confirmou os campos propostos como opcionais na preparação; confirmou que o NDA real continua pendente. Empresas: sectores do cadastro existente, receita/fluxo de caixa, moeda, motivo e disponibilidade declarada. Imóveis: modalidade, tipo existente, áreas/unidade, divisões, comodidades e disponibilidade. Reutiliza localização, rascunho, versões, submissão e revisão existentes.
|
||||
|
||||
Fotografias: máximo de seis activas, envio/alteração/retirada/restauro sob autorização e bloqueio do registo. Originais cifrados no disco privado; MIME, conteúdo, tamanho e quarentena no serviço existente. Declaração de edição digital conservada. Comprovativos imobiliários só podem ser lidos por validador interno com âmbito, MFA e sem conflito; dono/interessado/auditor/outro âmbito recusados. O controlo comum de binários impede atalhos; o envio não certifica titularidade.
|
||||
|
||||
Controlo empresarial exige aceitação do exemplar exacto do NDA e autorização expressa do vendedor para a mesma versão aprovada. Cada consulta revalida ambas, direitos e revisão de acesso. Retirada/revogação e alteração de versão fecham a consulta seguinte; nova aceitação exige nova autorização. História e auditoria preservam actores, instantes, hashes, versões e antes/depois; fundamentos cifrados. A leitura autorizada só entrega os campos empresariais conferidos, sem contactos, morada exacta ou comprovativos. NDA configurado nulo, sem texto inventado ou publicação. Fotografias originais continuam privadas.
|
||||
|
||||
Os cinco testes territoriais foram investigados: dependiam de Inhambane/turismo previamente importados numa base vazia. O fixture cria apenas os mínimos sintéticos quando faltam, com guarda testing/loopback/*_test:55441 e transacção de rollback. Os seis casos passaram; nenhum cadastro operacional foi alterado. Os snapshots F4 vinculam particularidades/recursos ao contrato v3; Ofertas comuns anteriores conservam v2.
|
||||
|
||||
Estado: implementado em QA, sem instalação, publicação efectiva, pagamentos, destaque comercial, migrações ou alterações em mne. Fase 5 não iniciada; módulo integral/Procuras parciais. Pendências: exemplar/versão/aprovação do NDA, identificação jurídica dos comprovativos admitidos, visual desktop/móvel e email real anteriores. GD ausente: derivadas do processador nativo continuam bloqueadas, sem simular processamento. Formatação global mantém 61 ficheiros exteriores ao delta. Relatório: docs/investmoz/v2/auditoria-portal/ofertas-fase4-entrega-qa-20261114.md. Recibo: outputs/offers-private-phase4-20261114/qa-result.json; contém resultados e hashes finais, sem somar execuções sobrepostas. Próximo passo: apreciação QA e disponibilização do NDA aprovado, mantendo o bloqueio de publicação.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,88 @@
|
||||
# Ofertas — Fase 4: particularidades e confidencialidade em QA
|
||||
|
||||
14/11/2026. Base aceite `7691cab3`, preservando F1 `1f2990e4`, F2 `91c18573` e o plano `6e302db6`. Desenvolvimento exclusivo em QA autorizado pela Tempo. Os campos propostos no documento original foram confirmados nesta sessão como opcionais durante a preparação. A Tempo confirmou também que o NDA permanece pendente.
|
||||
|
||||
**Implementado em QA, com dependência documental para o NDA real. Publicação efectiva bloqueada. Sem instalação, alterações de dados operacionais, migrações ou modificações em `mne`. Fase 5 não iniciada.**
|
||||
|
||||
## Percursos implementados
|
||||
|
||||
Na mesma preparação privada de Ofertas, o promotor guarda, recupera e altera:
|
||||
|
||||
- **Venda de empresa:** sectores principal/secundário do cadastro existente; receita, lucro/fluxo de caixa, moeda independente, motivo da venda e disponibilidade declarada. Decimais são conservados como texto, incluindo fluxo de caixa negativo; não há conversão cambial. Estes dados não entram na projecção pública.
|
||||
- **Imóvel:** venda/arrendamento, tipo do catálogo existente, áreas e unidade, quartos/casas de banho, características/comodidades e disponibilidade declarada. Os campos de quartos e casas de banho aparecem nos tipos habitacionais aplicáveis. A localização continua no selector existente. As categorias de direitos de terra e restantes produtos conservam os seus contratos; não se fundiu terreno com imóvel nem se inventou uma regra de titularidade.
|
||||
- **Fotografias:** envio, legenda, declaração e descrição de edição digital, alteração de metadados/original, retirada e restauro lógico. Máximo de **seis fotografias activas**, verificado no servidor sob bloqueio da mesma Oferta, tanto na criação como no restauro. Os recursos nativos dos outros módulos mantêm o limite anterior.
|
||||
- **Comprovativos imobiliários:** envio privado, estado de análise e consulta pelo validador interno autorizado. O anunciante recebe o estado do envio, mas não recebe uma ligação de leitura do comprovativo no editor. O suporte enviado não é apresentado como título certificado nem aprova o negócio.
|
||||
|
||||
Os ficheiros usam `OpportunityResources`, `documents`, ligações e histórico existentes, sem outro armazenamento documental. Originais são cifrados no disco privado `local`, com identificadores gerados pelo servidor, checksum e quarentena. Reutilizam-se validação MIME/conteúdo, limites de 10 MB para fotografia/20 MB para documento, mínimo de 400 × 400 píxeis e rejeição de imagens animadas/executáveis. Os formatos documentais são limites técnicos do serviço existente; não constituem uma lista jurídica de títulos admitidos. O scanner existente decide a disponibilidade. Nenhum envio passa automaticamente a limpo ou a publicado.
|
||||
|
||||
## Comprovativos e caminhos alternativos
|
||||
|
||||
A autorização comum de binários (`VerifiedDocumentBinary`) verifica também os comprovativos de Ofertas. Exige papel existente de validador com âmbito no registo, acesso efectivo, MFA recente e ausência de conflito; o promotor, auditor, outra categoria ou sessão sem MFA não obtêm os bytes. A verificação ocorre também nos consumidores gerais que reutilizam esse serviço, antes de qualquer entrega. Regista documento, hash, actor e versão da Oferta, sem incluir o original na auditoria.
|
||||
|
||||
As rotas nativas de edição de Ofertas que já estavam bloqueadas mantêm a recusa. A associação de originais públicos da biblioteca a uma Oferta é recusada neste percurso: um comprovativo reservado não é convertido em documento público por essa associação. Fotografias e localizações desta preparação só admitem visibilidade interna. Os comprovativos só admitem classificação restrita.
|
||||
|
||||
## Duas condições independentes para dados da empresa
|
||||
|
||||
O novo adaptador usa a mesma Oportunidade e a autorização existente. Não cria cadastro de empresas, utilizadores, permissões ou dashboard. A configuração `private_offers.nda` está **nula**; não contém texto jurídico nem exemplar fictício. A sua futura referência exige um documento existente com versão, SHA256 e referência da aprovação da Tempo. Estado, scan, versão e integridade dos bytes são reconferidos pelo serviço comum. Não se reutilizou a designação jurídica da legislação como aprovação automática de NDA.
|
||||
|
||||
Com um exemplar efectivamente aprovado, o percurso técnico é:
|
||||
|
||||
1. Interessado autenticado e com email verificado consulta o exemplar exacto e aceita-o expressamente para o hash da versão aprovada da empresa. Não é exigida uma justificação para aceitar ou retirar a aceitação.
|
||||
2. O vendedor registado (dono da Oferta), com atribuição de promotor e representação ainda efectivas, autoriza expressamente aquele interessado e **aquela aceitação**. Esta autorização no portal não certifica titularidade sobre a empresa. Um validador não substitui o vendedor. Autorizações e revogações conservam fundamento.
|
||||
3. Em cada consulta, o servidor reconfirma as duas condições, o exemplar/versão do NDA, a aprovação editorial actual, o hash dos dados, a identidade do vendedor, os direitos efectivos e a revisão de acesso do interessado.
|
||||
4. O interessado recebe apenas os campos empresariais da submissão aprovada. Não recebe a descrição privada integral, contactos, morada exacta, comprovativos, caminhos de armazenamento ou ficheiros originais. As fotografias continuam no percurso privado do promotor e da equipa; não se acrescentou partilha externa de originais.
|
||||
5. Revogar a autorização ou retirar a aceitação fecha o acesso seguinte. A retirada de uma aceitação anterior continua disponível enquanto a Oferta está em revisão, sem abrir os dados dessa revisão. Uma aceitação nova elimina a autorização anterior: exige nova decisão do vendedor. Mudar o NDA, a Oferta, os seus suportes, as referências abrangidas ou os direitos efectivos impede a reutilização silenciosa do acesso anterior.
|
||||
|
||||
Aceitações, autorizações, retiradas e revogações ficam na história da Oportunidade, com antes/depois, actor, instante, hash da Oferta e identidade/versão/checksum do NDA. Os fundamentos ficam cifrados. A auditoria existente regista operações e consultas autorizadas/recusadas, sem valores financeiros. O vendedor consulta as suas decisões; o interessado vê apenas as ocorrências que lhe respeitam. As respostas privadas mantêm `no-store`. Uma revogação não apaga informação que o interessado já tenha legitimamente recebido; não foi prometida recolha retroactiva de bytes.
|
||||
|
||||
## Continuidade editorial e protecção pública
|
||||
|
||||
Os campos específicos e os suportes activos são incorporados no snapshot editorial `private-offer-editorial-v3`. Hashes incluem identidades e versões dos recursos, conteúdo/metadados e checksums; alterações exigem nova submissão/decisão. Ofertas anteriores sem extensão mantêm o contrato v2. Identificadores, originais, rejeições, aprovações e histórico não são substituídos. Um suporte que volte a quarentena sinaliza aprovação desactualizada, conservando a possibilidade de recuperar e preparar nova revisão.
|
||||
|
||||
A projecção pública da Fase 3 e os bloqueios do modelo/workflow não foram abertos. Não existe publicador, pagamentos ou destaque comercial activo. Não se acrescentaram campos sensíveis a pesquisas, APIs públicas, mapas, CSV, notificações ou email. As provas anteriores da Fase 2 para notificações sem conteúdo privado são reutilizadas; a entrega real de email permanece pendente.
|
||||
|
||||
## Cinco testes territoriais antes bloqueados
|
||||
|
||||
A causa estava em `OpportunitySearchFixture`: exigia uma referência pública de Inhambane e o sector turismo previamente importados, apesar de o ensaio usar uma base de testes vazia. O produto não perdeu os dados territoriais. O fixture passa a criar os mínimos sintéticos apenas quando ausentes, **depois da guarda de ambiente testing/loopback/base `_test`/porta 55441 e dentro de uma transacção de rollback**. Fora dessa combinação recusa a criação. Não foi executada importação, reposição ou alteração no cadastro operacional.
|
||||
|
||||
Os seis casos de `OpportunitySearchTest`, incluindo os cinco impedidos, passaram. Não se alteraram as suas asserções ou os filtros do produto para obter aprovação.
|
||||
|
||||
## Ficheiros alterados
|
||||
|
||||
| Grupo | Finalidade |
|
||||
| --- | --- |
|
||||
| `PrivateOfferDetails`, `PrivateOfferPreparation` | Campos específicos e snapshots/versionamento existentes |
|
||||
| `PrivateOfferConfidentiality`, `config/private_offers.php` | Duas condições, revogação, versões e auditoria; NDA inactivo |
|
||||
| `OpportunityResources`, `OpportunityLibrarySupport`, `VerifiedDocumentBinary` | Limite de seis, metadados, visibilidade e protecção central dos comprovativos |
|
||||
| `PrivateOfferPreparationController`, `routes/web.php` | Operações privadas no percurso existente |
|
||||
| `offer-particulars`, `offer-private-resources`, `offer-confidential-access`, páginas `offers/preparation` e `offers/confidentiality` | Preparação progressiva, recursos e controlo privado de acesso |
|
||||
| `PrivateOfferConfidentialityTest`, `OpportunitySearchFixture` | Segurança/isolamento e independência das importações operacionais |
|
||||
| Matriz RF-MN-003, manual e recibo existentes | Estado, testes e dependências concretas |
|
||||
|
||||
## Verificação e saldo
|
||||
|
||||
Resultados finais, comandos, base isolada e hashes do delta: `outputs/offers-private-phase4-20261114/qa-result.json`. Grupos de testes sobrepostos não devem ser somados. A passagem alargada intermédia teve 48/50 aprovados; uma falha expôs uma referência de sessão desactualizada no cenário de revogação e a outra resultou de reconstruir assets durante a prova HTML. Foram corrigidas as causas e as verificações afectadas foram repetidas com o build estabilizado. Não se enfraqueceram asserções, permissões, scanner ou guardas.
|
||||
|
||||
| Verificação | Resultado |
|
||||
| --- | --- |
|
||||
| Passagem F4 e HTML das fases afectadas | 13 testes / 174 asserções aprovados |
|
||||
| Delta final: retirada durante revisão, versões NDA e revogação de sessão | 1 teste / 35 asserções aprovados |
|
||||
| HTML privado, público e de confidencialidade com o último build estabilizado | 3 testes / 29 asserções aprovados |
|
||||
| Regressão da preparação privada F1 | 14 testes / 146 asserções aprovados |
|
||||
| Regressão editorial F2 | 11 testes / 161 asserções aprovados |
|
||||
| Pesquisa territorial, incluindo os cinco casos antes impedidos | 6 testes / 112 asserções aprovados |
|
||||
| Recursos nativos: binários, uploads, autorização e processamento condicionado | 5 testes / 69 asserções aprovados |
|
||||
| Pint, PHPStan, TypeScript, lint/formatação focados e build QA | Aprovados; PHPStan sem erros |
|
||||
| Formatação global | Os mesmos 61 ficheiros, fora do delta e com conteúdo Git inalterado |
|
||||
|
||||
As regressões das Fases 1/2 e da pesquisa foram extraídas da passagem alargada; os cinco casos de recursos constam da passagem focada de 16 testes/217 asserções. O complemento final cobre a alteração de retirada sem repetir os restantes casos aprovados. O caso adicional de suporte novamente em quarentena teve a expectativa ajustada ao contrato **já existente** de reabertura (`needs_information`, editável), preservando o produto.
|
||||
|
||||
**Pendências concretas:**
|
||||
|
||||
- Exemplar, versão e referência da aprovação do NDA da Tempo. A ausência foi confirmada pelo proprietário; sem estes elementos o acesso real continua fechado. Os testes usam bytes explicitamente marcados como sintéticos e não jurídicos, por injecção local ao teste.
|
||||
- Identificação jurídica dos comprovativos imobiliários admitidos. Implementaram-se segurança e revisão interna, sem fabricar um tipo de título ou regra de propriedade.
|
||||
- Conferência visual desktop/móvel das fases aceites, ainda não concluída. O conector registou `The browser access host is unavailable`; não foram alterados navegadores ou controlos de segurança.
|
||||
- GD não está instalado neste PHP QA. Uploads, validação estrutural, armazenamento e autorização foram provados com PNGs válidos sem GD. O processador nativo mantém a geração de derivadas bloqueada quando GD/WebP não está disponível; não se declarou processamento de galerias públicas demonstrado.
|
||||
- A formatação global mantém os 61 ficheiros preexistentes fora do delta. Email real F2 e a abertura efectiva de publicação continuam com o seu estado anterior.
|
||||
|
||||
Próxima tarefa: apreciação desta entrega QA e disponibilização do exemplar aprovado do NDA, mantendo a publicação fechada. Qualquer instalação ou avanço para a Fase 5 exige autorização própria.
|
||||
@@ -0,0 +1 @@
|
||||
{"tool":"phpunit","result":"passed","tests":13,"passed":13,"assertions":174,"duration_ms":131026}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites>
|
||||
<testsuite name="CLI Arguments" tests="13" assertions="174" errors="0" failures="0" skipped="0" time="131.009743">
|
||||
<testsuite name="Tests\Feature\PrivateOfferConfidentialityTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" tests="11" assertions="156" errors="0" failures="0" skipped="0" time="128.514918">
|
||||
<testcase name="test_confirmed_specific_fields_save_recover_and_bind_the_editorial_version" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="107" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="20.768857"/>
|
||||
<testcase name="test_property_fields_are_optional_and_categories_and_unknown_fields_are_enforced" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="118" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="7" time="8.735438"/>
|
||||
<testcase name="test_six_photos_limit_includes_restoration_and_alternate_routes_and_cleans_failed_uploads" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="129" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="15" time="14.870475"/>
|
||||
<testcase name="test_upload_validation_and_quarantine_do_not_grant_publication_or_read_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="171" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="7.138552"/>
|
||||
<testcase name="test_proof_bytes_are_encrypted_and_only_a_scoped_internal_validator_can_read_them" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="185" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="14" time="6.180531"/>
|
||||
<testcase name="test_nda_absence_keeps_access_closed_and_acceptance_alone_never_grants_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="207" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="13" time="9.017397"/>
|
||||
<testcase name="test_both_conditions_exact_versions_revocation_and_audit_are_required_on_every_read" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="225" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="17" time="9.546498"/>
|
||||
<testcase name="test_nda_version_changes_withdrawal_session_revocation_and_private_revision_close_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="249" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="31" time="21.536494"/>
|
||||
<testcase name="test_nda_integrity_and_authority_loss_are_rechecked_without_exposing_the_company" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="286" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="14" time="10.637805"/>
|
||||
<testcase name="test_compiled_confidential_page_has_no_company_data_before_both_conditions" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="310" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="11" time="5.890419"/>
|
||||
<testcase name="test_photo_metadata_and_original_are_bound_to_the_editorial_fingerprint" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="324" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="16" time="14.192451"/>
|
||||
</testsuite>
|
||||
<testsuite name="Tests\Feature\OfferPublicCatalogueTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" tests="1" assertions="8" errors="0" failures="0" skipped="0" time="0.343878">
|
||||
<testcase name="test_public_html_resolves_the_real_compiled_pages_without_private_fields" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="188" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="8" time="0.343878"/>
|
||||
</testsuite>
|
||||
<testsuite name="Tests\Feature\PrivateOfferPreparationTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" tests="1" assertions="10" errors="0" failures="0" skipped="0" time="2.150948">
|
||||
<testcase name="test_full_html_uses_real_compiled_private_page_entries" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="241" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="10" time="2.150948"/>
|
||||
</testsuite>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
@@ -0,0 +1 @@
|
||||
{"tool":"phpunit","result":"passed","tests":16,"passed":16,"assertions":217,"duration_ms":133539}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites>
|
||||
<testsuite name="CLI Arguments" tests="16" assertions="217" errors="0" failures="0" skipped="0" time="133.498881">
|
||||
<testsuite name="Tests\Feature\PrivateOfferConfidentialityTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" tests="11" assertions="148" errors="0" failures="0" skipped="0" time="130.710864">
|
||||
<testcase name="test_confirmed_specific_fields_save_recover_and_bind_the_editorial_version" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="107" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="37.301637"/>
|
||||
<testcase name="test_property_fields_are_optional_and_categories_and_unknown_fields_are_enforced" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="118" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="7" time="5.927772"/>
|
||||
<testcase name="test_six_photos_limit_includes_restoration_and_alternate_routes_and_cleans_failed_uploads" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="129" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="15" time="12.326472"/>
|
||||
<testcase name="test_upload_validation_and_quarantine_do_not_grant_publication_or_read_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="171" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="7.960654"/>
|
||||
<testcase name="test_proof_bytes_are_encrypted_and_only_a_scoped_internal_validator_can_read_them" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="185" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="14" time="6.277187"/>
|
||||
<testcase name="test_nda_absence_keeps_access_closed_and_acceptance_alone_never_grants_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="207" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="13" time="9.438135"/>
|
||||
<testcase name="test_both_conditions_exact_versions_revocation_and_audit_are_required_on_every_read" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="224" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="17" time="10.122150"/>
|
||||
<testcase name="test_nda_version_changes_withdrawal_session_revocation_and_private_revision_close_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="248" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="31" time="19.336820"/>
|
||||
<testcase name="test_nda_integrity_and_authority_loss_are_rechecked_without_exposing_the_company" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="285" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="14" time="11.095258"/>
|
||||
<testcase name="test_compiled_confidential_page_has_no_company_data_before_both_conditions" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="309" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="11" time="7.443802"/>
|
||||
<testcase name="test_photo_metadata_and_original_are_bound_to_the_editorial_fingerprint" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="323" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="8" time="3.480976"/>
|
||||
</testsuite>
|
||||
<testsuite name="Tests\Feature\OpportunityResourcesTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunityResourcesTest.php" tests="5" assertions="69" errors="0" failures="0" skipped="0" time="2.788017">
|
||||
<testcase name="test_encrypted_original_scan_pending_recovery_replacement_and_logical_lifecycle" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunityResourcesTest.php" line="47" class="Tests\Feature\OpportunityResourcesTest" classname="Tests.Feature.OpportunityResourcesTest" assertions="24" time="0.726761"/>
|
||||
<testcase name="test_multiple_locations_without_coordinates_and_invalid_hierarchy_and_geometry" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunityResourcesTest.php" line="82" class="Tests\Feature\OpportunityResourcesTest" classname="Tests.Feature.OpportunityResourcesTest" assertions="14" time="0.436207"/>
|
||||
<testcase name="test_foreign_country_rejects_mozambican_hierarchy_without_changing_the_saved_draft" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunityResourcesTest.php" line="100" class="Tests\Feature\OpportunityResourcesTest" classname="Tests.Feature.OpportunityResourcesTest" assertions="20" time="0.596993"/>
|
||||
<testcase name="test_invalid_upload_and_cross_opportunity_links_do_not_create_resources" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunityResourcesTest.php" line="132" class="Tests\Feature\OpportunityResourcesTest" classname="Tests.Feature.OpportunityResourcesTest" assertions="7" time="0.496219"/>
|
||||
<testcase name="test_image_processing_is_explicit_and_every_derivative_requires_its_own_scan" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunityResourcesTest.php" line="141" class="Tests\Feature\OpportunityResourcesTest" classname="Tests.Feature.OpportunityResourcesTest" assertions="4" time="0.531838"/>
|
||||
</testsuite>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
@@ -0,0 +1 @@
|
||||
{"tool":"phpunit","result":"passed","tests":3,"passed":3,"assertions":29,"duration_ms":15844}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites>
|
||||
<testsuite name="CLI Arguments" tests="3" assertions="29" errors="0" failures="0" skipped="0" time="15.836319">
|
||||
<testsuite name="Tests\Feature\PrivateOfferConfidentialityTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" tests="1" assertions="11" errors="0" failures="0" skipped="0" time="13.410248">
|
||||
<testcase name="test_compiled_confidential_page_has_no_company_data_before_both_conditions" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="313" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="11" time="13.410248"/>
|
||||
</testsuite>
|
||||
<testsuite name="Tests\Feature\OfferPublicCatalogueTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" tests="1" assertions="8" errors="0" failures="0" skipped="0" time="0.201255">
|
||||
<testcase name="test_public_html_resolves_the_real_compiled_pages_without_private_fields" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="188" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="8" time="0.201255"/>
|
||||
</testsuite>
|
||||
<testsuite name="Tests\Feature\PrivateOfferPreparationTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" tests="1" assertions="10" errors="0" failures="0" skipped="0" time="2.224816">
|
||||
<testcase name="test_full_html_uses_real_compiled_private_page_entries" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="241" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="10" time="2.224816"/>
|
||||
</testsuite>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"phase": 4,
|
||||
"base": "7691cab3bc3ab8215c6feab1a2396119f07a55b5",
|
||||
"state": "implemented in QA; real NDA and visual review pending; publication blocked",
|
||||
"authorization": {
|
||||
"qa_only": true,
|
||||
"optional_proposed_fields_confirmed": true,
|
||||
"real_nda_pending_confirmed": true
|
||||
},
|
||||
"database": {
|
||||
"name": "offers_phase1_20261114_test",
|
||||
"port": 55441,
|
||||
"host": "127.0.0.1",
|
||||
"test_data": "synthetic with rollback"
|
||||
},
|
||||
"verified_runs": {
|
||||
"final.log": {
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 13,
|
||||
"passed": 13,
|
||||
"assertions": 174,
|
||||
"duration_ms": 131026
|
||||
},
|
||||
"withdrawal-latest.log": {
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 1,
|
||||
"passed": 1,
|
||||
"assertions": 35,
|
||||
"duration_ms": 27438
|
||||
},
|
||||
"html-latest.log": {
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 3,
|
||||
"passed": 3,
|
||||
"assertions": 29,
|
||||
"duration_ms": 15844
|
||||
},
|
||||
"focused.log": {
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 16,
|
||||
"passed": 16,
|
||||
"assertions": 217,
|
||||
"duration_ms": 133539
|
||||
}
|
||||
},
|
||||
"accepted_regressions": {
|
||||
"PrivateOfferPreparationTest": {
|
||||
"tests": 14,
|
||||
"assertions": 146,
|
||||
"errors": 0,
|
||||
"failures": 0,
|
||||
"evidence": "regression.xml"
|
||||
},
|
||||
"PrivateOfferEditorialTest": {
|
||||
"tests": 11,
|
||||
"assertions": 161,
|
||||
"errors": 0,
|
||||
"failures": 0,
|
||||
"evidence": "regression.xml"
|
||||
},
|
||||
"OpportunitySearchTest": {
|
||||
"tests": 6,
|
||||
"assertions": 112,
|
||||
"errors": 0,
|
||||
"failures": 0,
|
||||
"evidence": "regression.xml"
|
||||
},
|
||||
"OpportunityResourcesTest": {
|
||||
"tests": 5,
|
||||
"assertions": 69,
|
||||
"errors": 0,
|
||||
"failures": 0,
|
||||
"evidence": "focused.xml"
|
||||
}
|
||||
},
|
||||
"intermediate_run": {
|
||||
"tests": 50,
|
||||
"passed": 48,
|
||||
"assertions": 663,
|
||||
"failures": 2,
|
||||
"resolution": [
|
||||
"fresh access revision read from database and fresh synthetic actor",
|
||||
"HTML checked after build stabilized; no concurrent rebuild"
|
||||
]
|
||||
},
|
||||
"checks": {
|
||||
"pint": "passed",
|
||||
"phpstan": "passed; zero errors",
|
||||
"typescript": "passed",
|
||||
"frontend_focused": "five files passed",
|
||||
"build": "passed; build-latest.log",
|
||||
"git_diff_check": "passed",
|
||||
"global_format": {
|
||||
"failed_files": 61,
|
||||
"outside_delta": true,
|
||||
"unchanged_git_content": true,
|
||||
"same_list_as_accepted_baseline": true
|
||||
}
|
||||
},
|
||||
"limits": {
|
||||
"photos_active_per_offer": 6,
|
||||
"original_images_kib": 10240,
|
||||
"documents_kib": 20480,
|
||||
"image_min_pixels": [
|
||||
400,
|
||||
400
|
||||
]
|
||||
},
|
||||
"confidentiality": {
|
||||
"nda_configuration": null,
|
||||
"publication_enabled": false,
|
||||
"read_requires": [
|
||||
"current approved offer hash",
|
||||
"verified exact NDA bytes, version and approval reference",
|
||||
"interested acceptance",
|
||||
"express decision by registered offer owner",
|
||||
"current seller and interested permissions/access revision"
|
||||
],
|
||||
"proof_reader": "existing scoped internal validator with MFA and no conflict",
|
||||
"external_disclosure": "approved business fields only, no originals/contacts/exact coordinates/proof",
|
||||
"history": "offer context, versioned before/after; existing audit; encrypted reasons"
|
||||
},
|
||||
"territorial_tests": {
|
||||
"cause": "fixture depended on pre-existing imports in empty isolated database",
|
||||
"resolved_cases": 6,
|
||||
"previously_blocked": 5,
|
||||
"guard": "verified testing loopback *_test:55441 plus active rollback transaction",
|
||||
"operational_data_changed": false
|
||||
},
|
||||
"pending": [
|
||||
"real approved NDA exemplar/version/approval reference",
|
||||
"legal identification of acceptable property evidence; no title certification",
|
||||
"desktop/mobile visual acceptance: browser access host unavailable",
|
||||
"GD/WebP image derivatives not demonstrated in this PHP environment",
|
||||
"real email delivery from phase two",
|
||||
"61 pre-existing formatting failures"
|
||||
],
|
||||
"installed": false,
|
||||
"operational_data_modified": false,
|
||||
"migrations_added": false,
|
||||
"mne_modified": false,
|
||||
"phase5_started": false,
|
||||
"new_roles_or_authentication": false,
|
||||
"payments_or_paid_highlights": false,
|
||||
"source_hash_encoding": "SHA256 of UTF-8 files with Git LF normalization",
|
||||
"source_sha256": {
|
||||
"app/Http/Controllers/PrivateOfferPreparationController.php": "b684b14f85bd569c09dbd5f16d6e7f210c1045637d1e4c4c9229ffe124937e6c",
|
||||
"app/Services/OpportunityLibrarySupport.php": "26a08e3a358fe97f2609e275af72dd7e70ba777d2ad9ea791a3be2c1a4c08853",
|
||||
"app/Services/OpportunityResources.php": "e8bcd6f4f5b994b466005aace3601fcfc642550d6ebda227c6de7a498f95f1b5",
|
||||
"app/Services/PrivateOfferConfidentiality.php": "95bc76b9beb72924a4efdcbea6e8d36670f1ff15dac02baf0f761f489837e10b",
|
||||
"app/Services/PrivateOfferDetails.php": "1253c5d3cb9e3a2ae422f813afc4eb5ee8ed3859b07e15a3b32b678d3a97a015",
|
||||
"app/Services/PrivateOfferPreparation.php": "7b277a049442f90fcb15e913b43c551f2ba3716d54cfcfdcaf0b621fbf367a18",
|
||||
"app/Services/VerifiedDocumentBinary.php": "9a03b62643e767ef7d23da9faf235c491629d61201755ff8391b5cc7f534aaeb",
|
||||
"config/private_offers.php": "8414d6ffb362d5590d075a9c4205cd700f60b99f0cb5bd7e7f925b8638c60531",
|
||||
"docs/investmoz/unification/traceability-and-manual.md": "9cb4949a9279e3132a60069ff3cea21c12a4d5c90b8e44f0c1ed68428a07d656",
|
||||
"docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv": "c4715f857e750a4d11438284ee2fe7592af3d16b72862ba947f4539b5cb3fd35",
|
||||
"docs/investmoz/v2/auditoria-portal/ofertas-fase4-entrega-qa-20261114.md": "b4a5a8f06cb650885eb5af4de7e80d322d94f51af43509f893740bf5830fda61",
|
||||
"resources/js/components/offer-confidential-access.tsx": "5723e5e8eda7500fe24670e7676749a4f36ad294bd284224cf7b6919675bddc1",
|
||||
"resources/js/components/offer-particulars.tsx": "ef78a2a493705b6f4828addd0227fc7e91f511a48b2c7b12fbbe35c610fe5de0",
|
||||
"resources/js/components/offer-private-resources.tsx": "cb2070aeb4fd8ebe4eac28b41501223d2a87a2b1213538ab66c9520ab244d9c9",
|
||||
"resources/js/pages/offers/confidentiality.tsx": "b31d2cd72b55eaa9d04a3e7ff03fcb0d4a18696c403f89f5b791f9dc050c4d68",
|
||||
"resources/js/pages/offers/preparation.tsx": "238cf223eb88f2725b65c3700b977dd984e190a5e890f7b208e40fd34dcf1d8d",
|
||||
"routes/web.php": "1291c0ef1d7cfdef16c83f6444df62b03eff28e3193bb47cf21c1aac1aa51b13",
|
||||
"tests/Feature/PrivateOfferConfidentialityTest.php": "77eb38a9512914cea5e2554e00ef960abb3c861f6a3037a7504ca73db9c39161",
|
||||
"tests/Support/OpportunitySearchFixture.php": "1bab1218a1122f6a7713d1a256f8576f32dc03a2eff08601a1e356b840681364"
|
||||
},
|
||||
"evidence_sha256": {
|
||||
"final.log": "3a6a9976cabe1820641845e1546f626347636e999909e0a88e79798960a445f5",
|
||||
"final.xml": "38f4a99af98fb595dce559bc4a9e9bc0c06a4b6f92ec7b687631b8284aeacc28",
|
||||
"withdrawal-latest.log": "d01eff68d96bc8fe73d564e2aafc357a692676b4adeaf685a29c3221612b0a81",
|
||||
"withdrawal-latest.xml": "a51078aafee220a4134b20d3e864b59d54c96e95508d5fa5c9ceb267104bfafd",
|
||||
"html-latest.log": "99d084019996a9b8a3079e281c160e10a81afde635ba8e09011106579d955447",
|
||||
"html-latest.xml": "923911794057826eb12f4e0074ec283a9062d49037065ac3059d6b225dfe5c3c",
|
||||
"focused.log": "1e0789c1219d42f1d8f1952b64fdad6354261ec252826e79b8d35bd0db2c278d",
|
||||
"focused.xml": "a497520ddc934ac650f08ceb9d20f84dc32a9da8d0ec40e8bb99b3605aba10eb",
|
||||
"regression.xml": "2809cfa01d935ad0e332791dbc74be947ecf4c1ccfad03bbd52f633cdb8d8ce7"
|
||||
},
|
||||
"report": "docs/investmoz/v2/auditoria-portal/ofertas-fase4-entrega-qa-20261114.md",
|
||||
"overlapping_runs_not_summed": true
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites>
|
||||
<testsuite name="CLI Arguments" tests="50" assertions="663" errors="0" failures="2" skipped="0" time="312.052046">
|
||||
<testsuite name="Tests\Feature\PrivateOfferConfidentialityTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" tests="9" assertions="116" errors="0" failures="1" skipped="0" time="99.765962">
|
||||
<testcase name="test_confirmed_specific_fields_save_recover_and_bind_the_editorial_version" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="103" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="15.914456"/>
|
||||
<testcase name="test_property_fields_are_optional_and_categories_and_unknown_fields_are_enforced" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="114" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="7" time="7.085736"/>
|
||||
<testcase name="test_six_photos_limit_includes_restoration_and_alternate_routes_and_cleans_failed_uploads" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="125" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="15" time="12.561826"/>
|
||||
<testcase name="test_upload_validation_and_quarantine_do_not_grant_publication_or_read_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="167" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="8.641678"/>
|
||||
<testcase name="test_proof_bytes_are_encrypted_and_only_a_scoped_internal_validator_can_read_them" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="181" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="14" time="7.040351"/>
|
||||
<testcase name="test_nda_absence_keeps_access_closed_and_acceptance_alone_never_grants_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="203" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="12" time="10.011660"/>
|
||||
<testcase name="test_both_conditions_exact_versions_revocation_and_audit_are_required_on_every_read" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="219" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="17" time="11.019254"/>
|
||||
<testcase name="test_nda_version_changes_withdrawal_session_revocation_and_private_revision_close_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="243" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="20" time="17.736005">
|
||||
<failure type="PHPUnit\Framework\ExpectationFailedException">Tests\Feature\PrivateOfferConfidentialityTest::test_nda_version_changes_withdrawal_session_revocation_and_private_revision_close_access
|
||||
Expected response status code [200] but received 409.
|
||||
Failed asserting that 409 is identical to 200.
|
||||
|
||||
C:\laragon\www\portal-investidor-qa-editorial-20261113\vendor\laravel\framework\src\Illuminate\Testing\TestResponseAssert.php:46
|
||||
C:\laragon\www\portal-investidor-qa-editorial-20261113\vendor\laravel\framework\src\Illuminate\Testing\TestResponse.php:183
|
||||
C:\laragon\www\portal-investidor-qa-editorial-20261113\vendor\laravel\framework\src\Illuminate\Testing\Concerns\AssertsStatusCodes.php:16
|
||||
C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php:254
|
||||
C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php:270</failure>
|
||||
</testcase>
|
||||
<testcase name="test_nda_integrity_and_authority_loss_are_rechecked_without_exposing_the_company" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="280" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="13" time="9.754996"/>
|
||||
</testsuite>
|
||||
<testsuite name="Tests\Feature\PrivateOfferPreparationTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" tests="14" assertions="146" errors="0" failures="0" skipped="0" time="54.146736">
|
||||
<testcase name="test_private_creation_recovery_edit_conflict_and_idempotency_use_one_opportunity" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="60" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="24" time="6.934737"/>
|
||||
<testcase name="test_incomplete_draft_is_saved_but_submission_requires_common_fields_and_authorized_contact" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="78" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="7" time="1.585480"/>
|
||||
<testcase name="test_submit_is_private_exact_version_confirmed_and_recoverable_without_editorial_actions" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="87" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="17" time="4.918074"/>
|
||||
<testcase name="test_other_accounts_ungranted_unverified_and_revoked_actors_cannot_operate" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="110" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="18" time="4.184187"/>
|
||||
<testcase name="test_contact_scope_and_malicious_fields_roll_back_without_parallel_records" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="127" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="9" time="2.707529"/>
|
||||
<testcase name="test_price_contract_rejects_conversion_modes_precision_and_negotiable_amount" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="142" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="6" time="3.243489"/>
|
||||
<testcase name="test_no_public_exposure_and_native_routes_cannot_edit_or_publish_offer" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="152" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="15" time="3.322057"/>
|
||||
<testcase name="test_existing_opportunity_cannot_be_converted_through_offer_adapter" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="175" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="6" time="2.185731"/>
|
||||
<testcase name="test_existing_contacts_registration_still_requires_fresh_mfa_and_submission_rechecks_contact" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="191" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="10" time="8.805449"/>
|
||||
<testcase name="test_model_cannot_publish_or_remove_private_phase_marker" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="210" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="4" time="2.115354"/>
|
||||
<testcase name="test_location_uses_existing_hierarchy_and_invalid_selection_never_saves" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="225" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="6" time="3.387721"/>
|
||||
<testcase name="test_full_html_uses_real_compiled_private_page_entries" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="241" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="10" time="2.839783"/>
|
||||
<testcase name="test_organization_representation_is_rechecked_and_other_organizations_are_not_assignable" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="255" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="5" time="3.416102"/>
|
||||
<testcase name="test_incomplete_price_is_preserved_but_cannot_be_submitted_and_invalid_structures_return_validation_errors" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferPreparationTest.php" line="270" class="Tests\Feature\PrivateOfferPreparationTest" classname="Tests.Feature.PrivateOfferPreparationTest" assertions="9" time="4.501044"/>
|
||||
</testsuite>
|
||||
<testsuite name="Tests\Feature\PrivateOfferEditorialTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" tests="11" assertions="161" errors="0" failures="0" skipped="0" time="152.109179">
|
||||
<testcase name="test_submitted_offer_enters_existing_dashboard_and_private_approval_has_exact_snapshot_and_audit" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="83" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="31" time="10.376558"/>
|
||||
<testcase name="test_rejection_revision_correction_resubmission_preserve_identity_decisions_and_versions" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="113" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="27" time="35.198830"/>
|
||||
<testcase name="test_return_requires_reason_and_common_field_and_preserves_fields_on_conflict" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="151" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="11" time="11.573634"/>
|
||||
<testcase name="test_author_contributor_wrong_scope_auditor_and_unassigned_reviewer_cannot_approve" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="170" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="14" time="14.591862"/>
|
||||
<testcase name="test_reference_changes_block_approval_and_signal_reconfirmation_without_overwriting_decision" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="197" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="12" time="32.820792"/>
|
||||
<testcase name="test_phase_one_submission_is_bound_only_on_explicit_first_review" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="216" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="11" time="13.044988"/>
|
||||
<testcase name="test_notices_are_scoped_deduplicated_and_use_private_destination_email_rechecks_permission" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="237" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="10" time="5.295986"/>
|
||||
<testcase name="test_publication_and_alternative_routes_remain_blocked_after_private_approval" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="263" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="12" time="7.286119"/>
|
||||
<testcase name="test_rollback_preserves_draft_and_does_not_leave_tasks_notices_audit_or_send_mail" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="288" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="9" time="2.091156"/>
|
||||
<testcase name="test_disabling_editorial_does_not_downgrade_a_phase_two_submission_to_phase_one" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="316" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="8" time="7.864950"/>
|
||||
<testcase name="test_existing_coordinator_can_reassign_only_to_a_scoped_independent_validator" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferEditorialTest.php" line="332" class="Tests\Feature\PrivateOfferEditorialTest" classname="Tests.Feature.PrivateOfferEditorialTest" assertions="16" time="11.964305"/>
|
||||
</testsuite>
|
||||
<testsuite name="Tests\Feature\OfferPublicCatalogueTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" tests="10" assertions="128" errors="0" failures="1" skipped="0" time="2.983256">
|
||||
<testcase name="test_effective_publication_remains_closed_even_with_a_stored_projection" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="51" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="9" time="0.169435"/>
|
||||
<testcase name="test_pages_api_csv_and_map_only_use_the_explicit_public_projection" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="68" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="35" time="0.211112"/>
|
||||
<testcase name="test_invalid_unapproved_unpublished_or_sensitive_snapshots_are_not_readable" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="88" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="10" time="0.184474"/>
|
||||
<testcase name="test_pending_private_edits_do_not_replace_the_previously_approved_projection" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="101" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="6" time="0.163914"/>
|
||||
<testcase name="test_prices_are_exact_and_currencies_are_never_converted" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="117" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="12" time="0.201038"/>
|
||||
<testcase name="test_csv_formula_text_is_escaped_and_http_has_no_publication_operation" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="132" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="5" time="0.182387"/>
|
||||
<testcase name="test_location_category_and_pagination_use_only_the_published_snapshot" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="143" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="23" time="0.438636"/>
|
||||
<testcase name="test_public_html_resolves_the_real_compiled_pages_without_private_fields" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="188" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="3" time="0.163556">
|
||||
<failure type="PHPUnit\Framework\ExpectationFailedException">Tests\Feature\OfferPublicCatalogueTest::test_public_html_resolves_the_real_compiled_pages_without_private_fields
|
||||
Failed asserting that '<!DOCTYPE html>\n
|
||||
<html lang="pt" class="">\n
|
||||
<head>\n
|
||||
<meta charset="utf-8">\n
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">\n
|
||||
\n
|
||||
\n
|
||||
<script>\n
|
||||
(function() {\n
|
||||
const appearance = 'system';\n
|
||||
\n
|
||||
if (appearance === 'system') {\n
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n
|
||||
\n
|
||||
if (prefersDark) {\n
|
||||
document.documentElement.classList.add('dark');\n
|
||||
}\n
|
||||
}\n
|
||||
})();\n
|
||||
</script>\n
|
||||
\n
|
||||
\n
|
||||
<style>\n
|
||||
html {\n
|
||||
background-color: oklch(1 0 0);\n
|
||||
}\n
|
||||
\n
|
||||
html.dark {\n
|
||||
background-color: oklch(0.145 0 0);\n
|
||||
}\n
|
||||
</style>\n
|
||||
\n
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">\n
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">\n
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">\n
|
||||
\n
|
||||
<link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/instrument-sans-400-normal-DRC__1Mx.woff2" type="font/woff2" crossorigin="anonymous" />\n
|
||||
<link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/instrument-sans-500-normal-Dk9ku72i.woff2" type="font/woff2" crossorigin="anonymous" />\n
|
||||
<link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/instrument-sans-600-normal-B7fBEWYG.woff2" type="font/woff2" crossorigin="anonymous" />\n
|
||||
<link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/noto-sans-400-normal-BTkUljjl.woff2" type="font/woff2" crossorigin="anonymous" />\n
|
||||
<link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/noto-sans-500-normal-DCA2Z9Cj.woff2" type="font/woff2" crossorigin="anonymous" />\n
|
||||
<link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/noto-sans-600-normal-Dkgu5AE5.woff2" type="font/woff2" crossorigin="anonymous" />\n
|
||||
<link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/noto-sans-700-normal-CrvTEWhy.woff2" type="font/woff2" crossorigin="anonymous" />\n
|
||||
<link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/noto-serif-400-normal-BV_iYGc9.woff2" type="font/woff2" crossorigin="anonymous" />\n
|
||||
<link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/noto-serif-600-normal-BiuDA9L7.woff2" type="font/woff2" crossorigin="anonymous" />\n
|
||||
<link rel="preload" as="font" href="http://localhost/build-qa-opportunities/assets/noto-serif-700-normal-CIw17UNa.woff2" type="font/woff2" crossorigin="anonymous" />\n
|
||||
<style>\n
|
||||
@font-face {\n
|
||||
font-family: "Instrument Sans";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 400;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/instrument-sans-400-normal-DRC__1Mx.woff2") format("woff2");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Instrument Sans";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 400;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/instrument-sans-400-normal-D1W7dsQl.woff") format("woff");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Instrument Sans";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 500;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/instrument-sans-500-normal-Dk9ku72i.woff2") format("woff2");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Instrument Sans";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 500;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/instrument-sans-500-normal-Z6ESRlEs.woff") format("woff");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Instrument Sans";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 600;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/instrument-sans-600-normal-B7fBEWYG.woff2") format("woff2");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Instrument Sans";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 600;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/instrument-sans-600-normal-B9e8oLYv.woff") format("woff");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Noto Sans";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 400;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/noto-sans-400-normal-BTkUljjl.woff2") format("woff2");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Noto Sans";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 400;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/noto-sans-400-normal-DPpKfCgK.woff") format("woff");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Noto Sans";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 500;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/noto-sans-500-normal-DCA2Z9Cj.woff2") format("woff2");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Noto Sans";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 500;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/noto-sans-500-normal-CDPngY5C.woff") format("woff");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Noto Sans";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 600;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/noto-sans-600-normal-Dkgu5AE5.woff2") format("woff2");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Noto Sans";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 600;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/noto-sans-600-normal-DVJMj_t0.woff") format("woff");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Noto Sans";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 700;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/noto-sans-700-normal-CrvTEWhy.woff2") format("woff2");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Noto Sans";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 700;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/noto-sans-700-normal-ZyrKABVF.woff") format("woff");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Noto Serif";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 400;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/noto-serif-400-normal-BV_iYGc9.woff2") format("woff2");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Noto Serif";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 400;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/noto-serif-400-normal-DT0cwSa_.woff") format("woff");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Noto Serif";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 600;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/noto-serif-600-normal-BiuDA9L7.woff2") format("woff2");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Noto Serif";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 600;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/noto-serif-600-normal-RZopCp-J.woff") format("woff");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Noto Serif";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 700;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/noto-serif-700-normal-CIw17UNa.woff2") format("woff2");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
@font-face {\n
|
||||
font-family: "Noto Serif";\n
|
||||
font-style: normal;\n
|
||||
font-weight: 700;\n
|
||||
font-display: swap;\n
|
||||
src: url("/build-qa-opportunities/assets/noto-serif-700-normal-mX-AlFMT.woff") format("woff");\n
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n
|
||||
}\n
|
||||
\n
|
||||
:root {\n
|
||||
--font-instrument-sans: "Instrument Sans";\n
|
||||
--font-noto-sans: "Noto Sans";\n
|
||||
--font-noto-serif: "Noto Serif";\n
|
||||
}\n
|
||||
\n
|
||||
.font-instrument-sans {\n
|
||||
font-family: var(--font-instrument-sans);\n
|
||||
}\n
|
||||
\n
|
||||
.font-noto-sans {\n
|
||||
font-family: var(--font-noto-sans);\n
|
||||
}\n
|
||||
\n
|
||||
.font-noto-serif {\n
|
||||
font-family: var(--font-noto-serif);\n
|
||||
}\n
|
||||
</style>\n
|
||||
<link rel="preload" as="style" href="http://localhost/build-qa-opportunities/assets/app-UznX-FqS.css" /><link rel="preload" as="style" href="http://localhost/build-qa-opportunities/assets/interactive-investment-map-Bm3WjoQm.css" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/app-BPqa0WnF.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/rolldown-runtime-Dd_uD5pT.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/dist-BRfsww6z.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/jsx-runtime-BpzPEenQ.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/compiler-runtime-sKsOXjDo.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/dist-Ddn_hEUd.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/dist-BobFCjoG.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/dist-qw3FPjRO.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/Combination-BBDI-RbM.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/utils-CCDJ05Ll.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/createLucideIcon-oMeAlXbs.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/wayfinder-Dp7PqxvV.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/catalogue-dPWuPV9C.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/search-state-RHLy6yI7.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/shared-dS3P3an9.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/opportunity-search-filters-D6l6oeyA.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/search-BRvPeo47.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/sliders-horizontal-D-4FqAHA.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/investment-territory-Cnp2648A.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/territory-geometry-preview-BSCKCw5m.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/interactive-investment-map-soic1493.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/catalogue-investment-map-DSAPtwWw.js" /><link rel="modulepreload" as="script" href="http://localhost/build-qa-opportunities/assets/public-offer-card-Byw0miCA.js" /><link rel="stylesheet" href="http://localhost/build-qa-opportunities/assets/app-UznX-FqS.css" /><link rel="stylesheet" href="http://localhost/build-qa-opportunities/assets/interactive-investment-map-Bm3WjoQm.css" /><script type="module" src="http://localhost/build-qa-opportunities/assets/app-BPqa0WnF.js"></script><script type="module" src="http://localhost/build-qa-opportunities/assets/catalogue-dPWuPV9C.js"></script> <title>Laravel</title>\n
|
||||
</head>\n
|
||||
<body class="font-sans antialiased">\n
|
||||
<script data-page="app" type="application/json">{"component":"offers\/catalogue","props":{"errors":{},"name":"Laravel","administrationNavigation":[],"dashboardNavigation":[],"procurementEnabled":false,"fundingEnabled":true,"homologationReserved":false,"profileAccess":null,"contentManagementHref":null,"contentManagementAccess":[],"portalOperator":"Tempo, Lda.","portalInstitutionalContactEmail":"comercial@tempo.co.mz","legacyContentEnabled":false,"educationEnabled":false,"aiEnabled":false,"existingBusinessEnabled":false,"businessReviewUrl":null,"investorPathEnabled":false,"opportunityManagement":null,"flash":{"success":null,"warning":null,"uploaded_document_id":null},"scopedAdministration":[],"auth":{"user":null,"canManagePortal":false,"legacyManagementAccess":false},"sidebarOpen":true,"results":{"current_page":1,"data":[{"id":51,"title":"Synthetic public offer","summary":"Approved public summary.","category":"business_sale","location":[],"price":{"mode":"amount","amount":"12345.67","currency":"MZN"},"status":"published","published_at":"2026-11-13T15:10:01+00:00","revision":1,"url":"\/ofertas\/51"}],"first_page_url":"http:\/\/localhost\/ofertas?page=1","from":1,"last_page":1,"last_page_url":"http:\/\/localhost\/ofertas?page=1","links":[{"url":null,"label":"&laquo; Previous","page":null,"active":false},{"url":"http:\/\/localhost\/ofertas?page=1","label":"1","page":1,"active":true},{"url":null,"label":"Next &raquo;","page":null,"active":false}],"next_page_url":null,"path":"http:\/\/localhost\/ofertas","per_page":12,"prev_page_url":null,"to":1,"total":1},"truncated":false,"filters":[],"mapData":null,"territorySelection":[],"references":{"categories":["investment_project","real_estate","land_rights","business_sale","business_partnership","franchise","concession","financing","supply","other"],"options":[],"clusters":[],"corridors":[],"projects":[],"locations":[],"sectors":[]}},"url":"\/ofertas","version":"da4443275cc023299b6be2756e75d511","sharedProps":["errors","name","administrationNavigation","dashboardNavigation","procurementEnabled","fundingEnabled","homologationReserved","profileAccess","contentManagementHref","contentManagementAccess","portalOperator","portalInstitutionalContactEmail","legacyContentEnabled","educationEnabled","aiEnabled","existingBusinessEnabled","businessReviewUrl","investorPathEnabled","opportunityManagement","flash","scopedAdministration","auth","sidebarOpen"]}</script><div id="app"></div>\n
|
||||
</body>\n
|
||||
</html>\n
|
||||
' [ASCII](length: 16604) contains "assets/catalogue-QoLYHhHv.js" [ASCII](length: 28).
|
||||
|
||||
C:\laragon\www\portal-investidor-qa-editorial-20261113\vendor\laravel\framework\src\Illuminate\Testing\TestResponseAssert.php:46
|
||||
C:\laragon\www\portal-investidor-qa-editorial-20261113\vendor\laravel\framework\src\Illuminate\Testing\TestResponse.php:720
|
||||
C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php:198</failure>
|
||||
</testcase>
|
||||
<testcase name="test_existing_catalogue_keeps_its_own_currency_and_price_validation_contract" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="202" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="3" time="0.120404"/>
|
||||
<testcase name="test_native_combined_filters_and_export_with_self_contained_synthetic_references" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="210" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="22" time="1.148300"/>
|
||||
</testsuite>
|
||||
<testsuite name="Tests\Feature\OpportunitySearchTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunitySearchTest.php" tests="6" assertions="112" errors="0" failures="0" skipped="0" time="3.046914">
|
||||
<testcase name="test_one_park_selector_matches_public_associations_not_proximity_or_shared_territory" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunitySearchTest.php" line="26" class="Tests\Feature\OpportunitySearchTest" classname="Tests.Feature.OpportunitySearchTest" assertions="9" time="0.844338"/>
|
||||
<testcase name="test_historical_and_current_preparation_with_equal_numbers_are_not_equivalent" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunitySearchTest.php" line="38" class="Tests\Feature\OpportunitySearchTest" classname="Tests.Feature.OpportunitySearchTest" assertions="6" time="0.376099"/>
|
||||
<testcase name="test_amount_requires_currency_and_combines_with_sector_location_and_modality" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunitySearchTest.php" line="47" class="Tests\Feature\OpportunitySearchTest" classname="Tests.Feature.OpportunitySearchTest" assertions="10" time="0.536198"/>
|
||||
<testcase name="test_zone_options_are_not_filtered_by_opportunity_text_or_modality" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunitySearchTest.php" line="58" class="Tests\Feature\OpportunitySearchTest" classname="Tests.Feature.OpportunitySearchTest" assertions="7" time="0.271733"/>
|
||||
<testcase name="test_list_map_and_export_keep_the_same_public_park_results" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunitySearchTest.php" line="67" class="Tests\Feature\OpportunitySearchTest" classname="Tests.Feature.OpportunitySearchTest" assertions="16" time="0.454824"/>
|
||||
<testcase name="test_public_catalogue_consumers_keep_their_own_result_collections" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OpportunitySearchTest.php" line="80" class="Tests\Feature\OpportunitySearchTest" classname="Tests.Feature.OpportunitySearchTest" assertions="64" time="0.563721"/>
|
||||
</testsuite>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
@@ -0,0 +1 @@
|
||||
{"tool":"phpunit","result":"passed","tests":1,"passed":1,"assertions":35,"duration_ms":27438}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites>
|
||||
<testsuite name="Tests\Feature\PrivateOfferConfidentialityTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" tests="1" assertions="35" errors="0" failures="0" skipped="0" time="27.434490">
|
||||
<testcase name="test_nda_version_changes_withdrawal_session_revocation_and_private_revision_close_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="249" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="35" time="27.434490"/>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { profileJson } from '@/lib/profile-json';
|
||||
type Acceptance = { id: string; actor_id: number; version_hash: string };
|
||||
type Grant = { interested_user_id: number; version_hash: string };
|
||||
type Overview = {
|
||||
seller: boolean;
|
||||
nda_pending: boolean;
|
||||
can_accept: boolean;
|
||||
can_authorize: boolean;
|
||||
version_hash: string | null;
|
||||
nda: { sha256: string; version: number; fingerprint: string } | null;
|
||||
requests: Acceptance[];
|
||||
grants: Grant[];
|
||||
acceptance: Acceptance | null;
|
||||
etag: string | null;
|
||||
history: { id: string; action: string; at: string; version_hash: string }[];
|
||||
};
|
||||
export default function OfferConfidentialAccess({
|
||||
id,
|
||||
onChanged,
|
||||
}: {
|
||||
id: number;
|
||||
onChanged?: (etag: string) => void;
|
||||
}) {
|
||||
const [overview, setOverview] = useState<Overview | null>(null);
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [business, setBusiness] = useState<Record<
|
||||
string,
|
||||
string | number | null
|
||||
> | null>(null);
|
||||
const base = `/minhas-oportunidades/ofertas/${id}`;
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
profileJson(`${base}/confidencialidade`, {}, 'GET')
|
||||
.then((result) => {
|
||||
if (active) setOverview(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active) setMessage(error.message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [base]);
|
||||
async function act(action: string, target?: Acceptance | Grant) {
|
||||
if (!overview || !confirm || busy) return;
|
||||
setBusy(true);
|
||||
setBusiness(null);
|
||||
try {
|
||||
await profileJson(`${base}/confidencialidade`, {
|
||||
action,
|
||||
confirm,
|
||||
reason,
|
||||
version_hash: target?.version_hash ?? overview.version_hash,
|
||||
nda_sha256: overview.nda?.sha256,
|
||||
nda_fingerprint: overview.nda?.fingerprint,
|
||||
interested_user_id:
|
||||
target &&
|
||||
('actor_id' in target
|
||||
? target.actor_id
|
||||
: target.interested_user_id),
|
||||
acceptance_id: target && 'id' in target ? target.id : undefined,
|
||||
});
|
||||
const next = await profileJson(
|
||||
`${base}/confidencialidade`,
|
||||
{},
|
||||
'GET',
|
||||
);
|
||||
setOverview(next);
|
||||
if (typeof next.etag === 'string') onChanged?.(next.etag);
|
||||
setConfirm(false);
|
||||
setMessage('Decisão registada para a versão indicada.');
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Não foi possível registar.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function read() {
|
||||
setBusy(true);
|
||||
setBusiness(null);
|
||||
try {
|
||||
const result = await profileJson(`${base}/confidencial`, {}, 'GET');
|
||||
setBusiness(result.business);
|
||||
setMessage('Acesso autorizado e registado para esta versão.');
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error ? error.message : 'Acesso indisponível.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
sector: 'Sector principal',
|
||||
secondary_sector: 'Sector secundário',
|
||||
revenue: 'Receita',
|
||||
cash_flow: 'Lucro / fluxo de caixa',
|
||||
currency: 'Moeda',
|
||||
reason: 'Motivo da venda',
|
||||
availability: 'Disponibilidade declarada',
|
||||
};
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border p-4">
|
||||
<h2 className="font-semibold">Acesso confidencial à empresa</h2>
|
||||
<p>
|
||||
O acesso depende da aceitação do NDA e da autorização expressa
|
||||
do vendedor para a mesma versão.
|
||||
</p>
|
||||
{overview?.nda_pending && (
|
||||
<p>
|
||||
O exemplar aprovado do NDA ainda não foi disponibilizado. O
|
||||
acesso confidencial mantém-se fechado.
|
||||
</p>
|
||||
)}
|
||||
{overview?.nda && (
|
||||
<a className="underline" href={`${base}/nda`}>
|
||||
Consultar NDA — versão {overview.nda.version}
|
||||
</a>
|
||||
)}
|
||||
{overview && (
|
||||
<fieldset disabled={busy} className="space-y-3">
|
||||
<label className="block">
|
||||
{overview.seller
|
||||
? 'Fundamento da decisão'
|
||||
: 'Observação (opcional)'}
|
||||
<textarea
|
||||
className="block w-full rounded border p-2"
|
||||
minLength={overview.seller ? 20 : undefined}
|
||||
maxLength={1000}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirm}
|
||||
onChange={(e) => setConfirm(e.target.checked)}
|
||||
/>
|
||||
{overview.seller
|
||||
? 'Confirmo a decisão expressa para o interessado e versão indicados.'
|
||||
: 'Confirmo a operação para o exemplar e a versão indicados.'}
|
||||
</label>
|
||||
{!overview.seller && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border p-2"
|
||||
disabled={!confirm || !overview.can_accept}
|
||||
onClick={() => void act('accept')}
|
||||
>
|
||||
Li e aceito — registar aceitação
|
||||
</button>
|
||||
{overview.acceptance && (
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
disabled={!confirm}
|
||||
onClick={() =>
|
||||
void act(
|
||||
'withdraw',
|
||||
overview.acceptance!,
|
||||
)
|
||||
}
|
||||
>
|
||||
Retirar aceitação
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
disabled={overview.nda_pending}
|
||||
onClick={() => void read()}
|
||||
>
|
||||
Consultar dados autorizados
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{overview.seller && (
|
||||
<>
|
||||
<ul className="space-y-2">
|
||||
{overview.requests.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex flex-wrap gap-3"
|
||||
>
|
||||
Interessado #{item.actor_id} · versão{' '}
|
||||
{item.version_hash.slice(0, 12)}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
disabled={
|
||||
!confirm ||
|
||||
reason.trim().length < 20 ||
|
||||
!overview.can_authorize ||
|
||||
overview.version_hash !==
|
||||
item.version_hash
|
||||
}
|
||||
onClick={() =>
|
||||
void act('authorize', item)
|
||||
}
|
||||
>
|
||||
Autorizar esta aceitação
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul>
|
||||
{overview.grants.map((item) => (
|
||||
<li key={item.interested_user_id}>
|
||||
Autorização ao interessado #
|
||||
{item.interested_user_id}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-3 underline"
|
||||
disabled={
|
||||
!confirm ||
|
||||
reason.trim().length < 20
|
||||
}
|
||||
onClick={() =>
|
||||
void act('revoke', item)
|
||||
}
|
||||
>
|
||||
Revogar
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
{overview && overview.history.length > 0 && (
|
||||
<details>
|
||||
<summary>Histórico de acesso e decisões</summary>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{overview.history.map((event) => (
|
||||
<li key={event.id}>
|
||||
{(
|
||||
{
|
||||
accept: 'Aceitação do NDA',
|
||||
authorize: 'Autorização do vendedor',
|
||||
revoke: 'Autorização revogada',
|
||||
withdraw: 'Aceitação retirada',
|
||||
} as Record<string, string>
|
||||
)[event.action] ?? 'Decisão registada'}{' '}
|
||||
· {new Date(event.at).toLocaleString('pt-MZ')} ·
|
||||
versão {event.version_hash.slice(0, 12)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
{business && (
|
||||
<dl className="grid gap-2 sm:grid-cols-2">
|
||||
{Object.entries(business).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<dt className="font-semibold">
|
||||
{labels[key] ?? key}
|
||||
</dt>
|
||||
<dd className="break-words">
|
||||
{value ?? 'Não indicado'}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
<p role="status">{message}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
export type OfferParticulars = {
|
||||
business?: Record<string, string | number | null> | null;
|
||||
property?: Record<string, string | number | string[] | null> | null;
|
||||
};
|
||||
const control = 'w-full min-w-0 rounded-md border p-2';
|
||||
const propertyLabels: Record<string, string> = {
|
||||
house: 'Casa',
|
||||
apartment: 'Apartamento',
|
||||
office: 'Escritório',
|
||||
retail: 'Espaço comercial',
|
||||
warehouse: 'Armazém',
|
||||
hospitality: 'Hotelaria',
|
||||
industrial: 'Industrial',
|
||||
mixed_use: 'Uso misto',
|
||||
development: 'Empreendimento',
|
||||
other: 'Outro',
|
||||
};
|
||||
|
||||
export default function OfferParticularsFields({
|
||||
value,
|
||||
category,
|
||||
sectors,
|
||||
propertyTypes,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: OfferParticulars;
|
||||
category: string;
|
||||
sectors: { id: number; name: string }[];
|
||||
propertyTypes: string[];
|
||||
disabled: boolean;
|
||||
onChange: (next: OfferParticulars) => void;
|
||||
}) {
|
||||
if (!['business_sale', 'real_estate'].includes(category)) return null;
|
||||
const business = category === 'business_sale';
|
||||
const group = business ? 'business' : 'property';
|
||||
const data = value[group] ?? {};
|
||||
const update = (key: string, next: string | string[] | null) =>
|
||||
onChange({ ...value, [group]: { ...data, [key]: next } });
|
||||
const text = (key: string, label: string, numeric = false) => (
|
||||
<label key={key} className="space-y-1">
|
||||
{label}
|
||||
<input
|
||||
className={control}
|
||||
value={String(data[key] ?? '')}
|
||||
inputMode={numeric ? 'decimal' : 'text'}
|
||||
maxLength={numeric ? 30 : 1000}
|
||||
onChange={(e) => update(key, e.target.value || null)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
const select = (
|
||||
key: string,
|
||||
label: string,
|
||||
options: { value: string; label: string }[],
|
||||
) => (
|
||||
<label key={key} className="space-y-1">
|
||||
{label}
|
||||
<select
|
||||
className={control}
|
||||
value={String(data[key] ?? '')}
|
||||
onChange={(e) => update(key, e.target.value || null)}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{options.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
return (
|
||||
<fieldset
|
||||
disabled={disabled}
|
||||
className="space-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<legend className="px-2 font-semibold">
|
||||
{business
|
||||
? 'Dados da empresa — acesso reservado'
|
||||
: 'Características do imóvel'}
|
||||
</legend>
|
||||
<p className="text-sm">
|
||||
Campos opcionais durante a preparação. A gravação não publica
|
||||
estes dados.
|
||||
</p>
|
||||
<div className="grid min-w-0 gap-4 sm:grid-cols-2">
|
||||
{business ? (
|
||||
<>
|
||||
{['sector_id', 'secondary_sector_id'].map(
|
||||
(key, index) =>
|
||||
select(
|
||||
key,
|
||||
index
|
||||
? 'Sector secundário'
|
||||
: 'Sector principal',
|
||||
sectors.map((sector) => ({
|
||||
value: String(sector.id),
|
||||
label: sector.name,
|
||||
})),
|
||||
),
|
||||
)}
|
||||
{text('revenue', 'Receita resumida', true)}
|
||||
{text(
|
||||
'cash_flow',
|
||||
'Lucro ou fluxo de caixa resumido',
|
||||
true,
|
||||
)}
|
||||
{select('currency', 'Moeda dos valores da empresa', [
|
||||
{ value: 'MZN', label: 'MZN' },
|
||||
{ value: 'USD', label: 'USD' },
|
||||
])}
|
||||
{text('reason', 'Motivo da venda')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{select('transaction_type', 'Tipo de negócio', [
|
||||
{ value: 'sale', label: 'Venda' },
|
||||
{ value: 'lease', label: 'Arrendamento' },
|
||||
])}
|
||||
{select(
|
||||
'property_type',
|
||||
'Tipo de imóvel',
|
||||
propertyTypes.map((type) => ({
|
||||
value: type,
|
||||
label: propertyLabels[type] ?? type,
|
||||
})),
|
||||
)}
|
||||
{text('land_area', 'Área do terreno', true)}
|
||||
{text('built_area', 'Área construída', true)}
|
||||
{select('area_unit', 'Unidade da área', [
|
||||
{ value: 'sqm', label: 'm²' },
|
||||
{ value: 'ha', label: 'ha' },
|
||||
{ value: 'sqkm', label: 'km²' },
|
||||
])}
|
||||
{[
|
||||
'house',
|
||||
'apartment',
|
||||
'hospitality',
|
||||
'mixed_use',
|
||||
].includes(String(data.property_type)) && (
|
||||
<>
|
||||
{text('bedrooms', 'Quartos', true)}
|
||||
{text('bathrooms', 'Casas de banho', true)}
|
||||
</>
|
||||
)}
|
||||
<label>
|
||||
Características e comodidades (uma por linha)
|
||||
<textarea
|
||||
className={control}
|
||||
value={
|
||||
Array.isArray(data.amenities)
|
||||
? data.amenities.join('\n')
|
||||
: ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
update(
|
||||
'amenities',
|
||||
e.target.value
|
||||
.split('\n')
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{select('availability', 'Disponibilidade declarada', [
|
||||
{ value: 'available', label: 'Disponível' },
|
||||
{ value: 'sold', label: 'Vendido' },
|
||||
...(!business
|
||||
? [{ value: 'leased', label: 'Arrendado' }]
|
||||
: []),
|
||||
])}
|
||||
</div>
|
||||
{value[business ? 'property' : 'business'] && (
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...value,
|
||||
[business ? 'property' : 'business']: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
Retirar os dados do tipo anterior antes de guardar esta
|
||||
categoria
|
||||
</button>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { profileJson } from '@/lib/profile-json';
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
type Resource = {
|
||||
id: string;
|
||||
version: number;
|
||||
deleted: boolean;
|
||||
caption: string | null;
|
||||
digitally_edited: boolean | null;
|
||||
edit_description: string | null;
|
||||
scan: string;
|
||||
};
|
||||
export default function OfferPrivateResources({
|
||||
id,
|
||||
etag,
|
||||
editable,
|
||||
property,
|
||||
disabled,
|
||||
onChanged,
|
||||
}: {
|
||||
id: number;
|
||||
etag: string;
|
||||
editable: boolean;
|
||||
property: boolean;
|
||||
disabled: boolean;
|
||||
onChanged: (etag: string) => void;
|
||||
}) {
|
||||
const [resources, setResources] = useState<{
|
||||
images: Resource[];
|
||||
documents: Resource[];
|
||||
}>({ images: [], documents: [] });
|
||||
const [internal, setInternal] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [kind, setKind] = useState('images');
|
||||
const [caption, setCaption] = useState('');
|
||||
const [edited, setEdited] = useState(false);
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [editing, setEditing] = useState<Resource | null>(null);
|
||||
const base = `/minhas-oportunidades/ofertas/${id}/recursos`;
|
||||
async function refresh() {
|
||||
const result = await profileJson(base, {}, 'GET');
|
||||
setResources(result.resources);
|
||||
setInternal(result.internalProofAccess);
|
||||
onChanged(result.etag);
|
||||
}
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
profileJson(base, {}, 'GET')
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
setResources(result.resources);
|
||||
setInternal(result.internalProofAccess);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active) setMessage(error.message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [base]);
|
||||
async function upload() {
|
||||
if ((!file && !editing) || busy || disabled) return;
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
const form = new FormData();
|
||||
if (file) form.append('file', file);
|
||||
form.append('etag', etag);
|
||||
if (editing) form.append('version', String(editing.version));
|
||||
else form.append('creation_key', createOperationKey());
|
||||
form.append(
|
||||
'reason',
|
||||
'Envio privado de suporte da oferta pelo promotor.',
|
||||
);
|
||||
form.append(
|
||||
'payload',
|
||||
JSON.stringify(
|
||||
kind === 'images'
|
||||
? {
|
||||
visibility: 'internal',
|
||||
caption,
|
||||
alt_text: caption,
|
||||
digitally_edited: edited,
|
||||
edit_description: edited ? editDescription : null,
|
||||
}
|
||||
: {
|
||||
visibility: 'internal',
|
||||
classification: 'restricted',
|
||||
category: 'real_estate_proof',
|
||||
},
|
||||
),
|
||||
);
|
||||
try {
|
||||
await profileJson(
|
||||
editing
|
||||
? `${base}/images/${editing.id}/guardar`
|
||||
: `${base}/${kind}`,
|
||||
form,
|
||||
);
|
||||
await refresh();
|
||||
setFile(null);
|
||||
setEditing(null);
|
||||
setMessage(
|
||||
'Original guardado em privado. A análise de segurança pode ainda estar pendente.',
|
||||
);
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Não foi possível guardar.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function lifecycle(resource: Resource, resourceKind: string) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await profileJson(`${base}/${resourceKind}/${resource.id}`, {
|
||||
etag,
|
||||
version: resource.version,
|
||||
action: resource.deleted ? 'restore' : 'delete',
|
||||
confirm: true,
|
||||
reason: 'Alteração explícita dos suportes privados da oferta.',
|
||||
});
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Não foi possível alterar.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="space-y-4 rounded-lg border p-4">
|
||||
<h2 className="font-semibold">Fotografias e suportes privados</h2>
|
||||
<p>
|
||||
Até seis fotografias. JPEG, PNG ou WebP, até 10 MB e pelo menos
|
||||
400 × 400 píxeis. Os comprovativos são reservados à validação
|
||||
interna; o envio não certifica titularidade.
|
||||
</p>
|
||||
{disabled && <p>Guarde os dados antes de alterar os ficheiros.</p>}
|
||||
{editable && (
|
||||
<fieldset
|
||||
disabled={busy || disabled}
|
||||
className="grid gap-3 sm:grid-cols-2"
|
||||
>
|
||||
<label>
|
||||
Tipo
|
||||
<select
|
||||
className="w-full rounded border p-2"
|
||||
value={kind}
|
||||
disabled={editing !== null}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
>
|
||||
<option value="images">Fotografia</option>
|
||||
{property && (
|
||||
<option value="documents">
|
||||
Comprovativo imobiliário
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Ficheiro
|
||||
<input
|
||||
type="file"
|
||||
className="block w-full min-w-0"
|
||||
accept={
|
||||
kind === 'images'
|
||||
? 'image/jpeg,image/png,image/webp'
|
||||
: '.pdf,.docx,.xlsx,.png,.jpg,.jpeg'
|
||||
}
|
||||
onChange={(e) =>
|
||||
setFile(e.target.files?.[0] ?? null)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{kind === 'images' && (
|
||||
<>
|
||||
<label>
|
||||
Legenda / divisão
|
||||
<input
|
||||
className="w-full rounded border p-2"
|
||||
maxLength={250}
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={edited}
|
||||
onChange={(e) =>
|
||||
setEdited(e.target.checked)
|
||||
}
|
||||
/>{' '}
|
||||
Imagem editada digitalmente
|
||||
</label>
|
||||
{edited && (
|
||||
<label>
|
||||
Alterações efectuadas
|
||||
<textarea
|
||||
className="w-full rounded border p-2"
|
||||
maxLength={1000}
|
||||
value={editDescription}
|
||||
onChange={(e) =>
|
||||
setEditDescription(e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border p-2"
|
||||
disabled={
|
||||
(!file && !editing) ||
|
||||
(!editing &&
|
||||
kind === 'images' &&
|
||||
resources.images.filter((item) => !item.deleted)
|
||||
.length >= 6)
|
||||
}
|
||||
onClick={() => void upload()}
|
||||
>
|
||||
{editing
|
||||
? 'Guardar alterações da fotografia'
|
||||
: 'Guardar ficheiro privado'}
|
||||
</button>
|
||||
</fieldset>
|
||||
)}
|
||||
{editing && (
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setFile(null);
|
||||
}}
|
||||
>
|
||||
Cancelar alteração da fotografia
|
||||
</button>
|
||||
)}
|
||||
{(['images', 'documents'] as const).map((resourceKind) => (
|
||||
<ul key={resourceKind} className="space-y-2">
|
||||
{resources[resourceKind].map((resource) => (
|
||||
<li
|
||||
key={resource.id}
|
||||
className="flex flex-wrap gap-3 break-words"
|
||||
>
|
||||
<span>
|
||||
{resource.caption ||
|
||||
(resourceKind === 'images'
|
||||
? 'Fotografia'
|
||||
: 'Comprovativo')}{' '}
|
||||
· versão {resource.version} ·{' '}
|
||||
{resource.deleted
|
||||
? 'Retirado'
|
||||
: resource.scan === 'clean'
|
||||
? 'Análise concluída'
|
||||
: 'Análise de segurança pendente'}
|
||||
</span>
|
||||
{!resource.deleted &&
|
||||
resource.scan === 'clean' &&
|
||||
(resourceKind === 'images' || internal) && (
|
||||
<a
|
||||
className="underline"
|
||||
href={`${base}/${resourceKind}/${resource.id}`}
|
||||
>
|
||||
Consultar original
|
||||
</a>
|
||||
)}
|
||||
{editable &&
|
||||
resourceKind === 'images' &&
|
||||
!resource.deleted && (
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
disabled={busy || disabled}
|
||||
onClick={() => {
|
||||
setEditing(resource);
|
||||
setKind('images');
|
||||
setCaption(resource.caption ?? '');
|
||||
setEdited(
|
||||
resource.digitally_edited ??
|
||||
false,
|
||||
);
|
||||
setEditDescription(
|
||||
resource.edit_description ?? '',
|
||||
);
|
||||
setFile(null);
|
||||
}}
|
||||
>
|
||||
Alterar fotografia
|
||||
</button>
|
||||
)}
|
||||
{editable && (
|
||||
<button
|
||||
className="underline"
|
||||
disabled={busy || disabled}
|
||||
onClick={() =>
|
||||
void lifecycle(resource, resourceKind)
|
||||
}
|
||||
>
|
||||
{resource.deleted ? 'Restaurar' : 'Retirar'}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
disabled={busy || disabled}
|
||||
onClick={() =>
|
||||
void refresh().catch((error) => setMessage(error.message))
|
||||
}
|
||||
>
|
||||
Actualizar estado dos ficheiros
|
||||
</button>
|
||||
<p role="status">{message}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import OfferConfidentialAccess from '@/components/offer-confidential-access';
|
||||
export default function OfferConfidentiality({ offerId }: { offerId: number }) {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-3xl space-y-4 p-4">
|
||||
<Head title="Acesso confidencial" />
|
||||
<h1 className="text-2xl font-semibold">Acesso confidencial</h1>
|
||||
<OfferConfidentialAccess id={offerId} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,13 @@ import { createOperationKey } from '@/lib/operation-key';
|
||||
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
|
||||
import OfferEditorialReview from '@/components/offer-editorial-review';
|
||||
import { type OfferEditorialRecord } from '@/lib/offer-editorial';
|
||||
import OfferParticularsFields, {
|
||||
type OfferParticulars,
|
||||
} from '@/components/offer-particulars';
|
||||
import OfferPrivateResources from '@/components/offer-private-resources';
|
||||
import OfferConfidentialAccess from '@/components/offer-confidential-access';
|
||||
|
||||
type Offer = {
|
||||
type Offer = OfferParticulars & {
|
||||
price_mode?: string | null;
|
||||
amount?: string | null;
|
||||
currency?: string | null;
|
||||
@@ -49,11 +54,15 @@ export default function OfferPreparation({
|
||||
contexts,
|
||||
contacts,
|
||||
contactInvestors,
|
||||
sectors,
|
||||
propertyTypes,
|
||||
}: {
|
||||
record: RecordData | null;
|
||||
contexts: Context[];
|
||||
contacts: Contact[];
|
||||
contactInvestors: { id: string; legal_name: string }[];
|
||||
sectors: { id: number; name: string }[];
|
||||
propertyTypes: string[];
|
||||
}) {
|
||||
const [saved, setSaved] = useState(record);
|
||||
const [creationKey] = useState(createOperationKey);
|
||||
@@ -417,6 +426,41 @@ export default function OfferPreparation({
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
<OfferParticularsFields
|
||||
value={offer}
|
||||
category={context.type}
|
||||
sectors={sectors}
|
||||
propertyTypes={propertyTypes}
|
||||
disabled={busy || readOnly}
|
||||
onChange={(next) => updateOffer({ ...offer, ...next })}
|
||||
/>
|
||||
{saved && (
|
||||
<OfferPrivateResources
|
||||
id={saved.id}
|
||||
etag={saved.etag}
|
||||
editable={saved.canEdit}
|
||||
property={context.type === 'real_estate'}
|
||||
disabled={dirty || busy}
|
||||
onChanged={(etag) => {
|
||||
setSaved((previous) =>
|
||||
previous ? { ...previous, etag } : previous,
|
||||
);
|
||||
setConfirmed(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{saved && context.type === 'business_sale' && (
|
||||
<OfferConfidentialAccess
|
||||
key={`${saved.id}-${saved.state}`}
|
||||
id={saved.id}
|
||||
onChanged={(etag) => {
|
||||
setSaved((previous) =>
|
||||
previous ? { ...previous, etag } : previous,
|
||||
);
|
||||
setConfirmed(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{saved?.canEdit && (
|
||||
<details className="rounded-lg border p-4">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
|
||||
@@ -243,6 +243,15 @@ Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(func
|
||||
Route::post('/{id}/guardar', [$offers, 'save'])->whereNumber('id');
|
||||
Route::post('/{id}/submeter', [$offers, 'submit'])->whereNumber('id');
|
||||
Route::post('/{id}/revisao', [$offers, 'transition'])->whereNumber('id');
|
||||
Route::get('/{id}/recursos', [$offers, 'resources'])->whereNumber('id');
|
||||
Route::post('/{id}/recursos/{kind}', [OpportunityResourceController::class, 'save'])->whereNumber('id')->whereIn('kind', ['images', 'documents']);
|
||||
Route::post('/{id}/recursos/{kind}/{resourceId}/guardar', [OpportunityResourceController::class, 'save'])->whereNumber('id')->whereIn('kind', ['images', 'documents'])->whereUuid('resourceId');
|
||||
Route::post('/{id}/recursos/{kind}/{resourceId}', [OpportunityResourceController::class, 'lifecycle'])->whereNumber('id')->whereIn('kind', ['images', 'documents'])->whereUuid('resourceId');
|
||||
Route::get('/{id}/recursos/{kind}/{resourceId}', [$offers, 'attachment'])->whereNumber('id')->whereIn('kind', ['images', 'documents'])->whereUuid('resourceId');
|
||||
Route::get('/{id}/confidencialidade', [$offers, 'confidentiality'])->whereNumber('id');
|
||||
Route::post('/{id}/confidencialidade', [$offers, 'confidentialityAction'])->whereNumber('id');
|
||||
Route::get('/{id}/confidencial', [$offers, 'confidentialRead'])->whereNumber('id');
|
||||
Route::get('/{id}/nda', [$offers, 'nda'])->whereNumber('id');
|
||||
});
|
||||
Route::get('/minhas-oportunidades/oferta-interna', [ProactiveSupplyController::class, 'index'])->middleware('throttle:60,1');
|
||||
Route::get('/minhas-oportunidades/oferta-interna/revisao', [ProactiveSupplyController::class, 'review'])->middleware(['throttle:60,1', PrivateInvestmentResponse::class]);
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\PortalProfileAdministration;
|
||||
use App\Services\PrivateOfferConfidentiality;
|
||||
use App\Services\PrivateOfferPreparation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Foundation\Vite;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Tests\Support\CleanScannerFixture;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class PrivateOfferConfidentialityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const BASE = '/minhas-oportunidades/ofertas';
|
||||
|
||||
private const REASON = 'Operação exclusivamente sintética na base QA isolada.';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
Storage::fake('local');
|
||||
Queue::fake();
|
||||
Mail::fake();
|
||||
config(['portal.profile_access_enabled' => true, 'editorial_workflow.enabled' => true, 'private_offers.nda' => null]);
|
||||
}
|
||||
|
||||
private function actor(?string $role = null, string $category = 'business_sale'): User
|
||||
{
|
||||
$actor = User::factory()->create(['two_factor_secret' => Crypt::encryptString('SYNTHETIC-QA'), 'two_factor_confirmed_at' => now()]);
|
||||
if ($role) {
|
||||
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [
|
||||
'user_id' => $actor->id, 'role' => $role, 'organization_id' => null, 'responsibilities' => [], 'opportunity_types' => [$category], 'source' => 'synthetic-test-fixture']);
|
||||
}
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
private function login(User $actor): static
|
||||
{
|
||||
$actor = $actor->fresh();
|
||||
|
||||
return $this->actingAs($actor)->withSession(['login.id' => null, 'portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->fresh()->portal_access_revision]);
|
||||
}
|
||||
|
||||
private function draft(User $owner, string $category = 'business_sale', array $details = []): Opportunity
|
||||
{
|
||||
$contact = (string) Str::uuid();
|
||||
DB::table('contacts')->insert(['id' => $contact, 'full_name' => 'PRIVATE-CONTACT-CANARY', 'source_system' => 'portal-user-contact', 'external_id' => 'user:'.$owner->id]);
|
||||
$result = $this->login($owner)->postJson(self::BASE.'/rascunhos', ['creation_key' => (string) Str::uuid(), 'title' => 'Synthetic QA private offer',
|
||||
'description' => 'PRIVATE-DESCRIPTION-CANARY', 'opportunity_category' => $category,
|
||||
'private_offer' => ['price_mode' => 'amount', 'amount' => '100.01', 'currency' => 'MZN', 'contact_id' => $contact, ...$details], 'reason' => self::REASON])->assertCreated();
|
||||
|
||||
return Opportunity::findOrFail($result->json('record.id'));
|
||||
}
|
||||
|
||||
private function etag(Opportunity $record): string
|
||||
{
|
||||
return app(OpportunityManagement::class)->etag($record->fresh());
|
||||
}
|
||||
|
||||
private function approve(User $owner, Opportunity $record): void
|
||||
{
|
||||
$this->login($owner)->postJson(self::BASE.'/'.$record->id.'/submeter', ['etag' => $this->etag($record), 'confirm' => true])->assertOk();
|
||||
$reviewer = $this->actor('validator', $record->opportunity_category);
|
||||
foreach (['start_review', 'approve'] as $action) {
|
||||
$this->login($reviewer)->postJson(self::BASE.'/'.$record->id.'/revisao', ['etag' => $this->etag($record), 'action' => $action, 'reason' => self::REASON])->assertOk();
|
||||
}
|
||||
}
|
||||
|
||||
private function nda(): array
|
||||
{
|
||||
// Deliberately non-legal bytes, never registered as an operational NDA.
|
||||
$bytes = "%PDF-1.7\nSYNTHETIC QA - NOT A LEGAL AGREEMENT\n%%EOF";
|
||||
$id = (string) Str::uuid();
|
||||
$sha = hash('sha256', $bytes);
|
||||
Storage::disk('local')->put('qa-nda/'.$id, Crypt::encryptString($bytes));
|
||||
DB::table('documents')->insert(['id' => $id, 'file_name' => 'synthetic-qa.pdf', 'media_type' => 'application/pdf', 'storage_key' => 'qa-nda/'.$id,
|
||||
'storage_encryption' => 'laravel', 'sha256' => $sha, 'status' => 'active', 'malware_scan_status' => 'clean', 'malware_scanned_sha256' => $sha,
|
||||
'malware_engine' => 'QA-Test-Double', 'version_no' => 1, 'data_classification' => 'restricted']);
|
||||
config(['private_offers.nda' => ['document_id' => $id, 'version' => 1, 'sha256' => $sha, 'approval_reference' => 'SYNTHETIC-QA-NOT-LEGAL']]);
|
||||
|
||||
return app(PrivateOfferConfidentiality::class)->nda();
|
||||
}
|
||||
|
||||
private function command(Opportunity $offer, array $nda, string $action, array $extra = []): array
|
||||
{
|
||||
return ['action' => $action, 'version_hash' => $offer->fresh()->workflow_context['approval']['hash'], 'nda_sha256' => $nda['sha256'],
|
||||
'nda_fingerprint' => $nda['fingerprint'], 'confirm' => true, 'reason' => self::REASON, ...$extra];
|
||||
}
|
||||
|
||||
public function test_confirmed_specific_fields_save_recover_and_bind_the_editorial_version(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$record = $this->draft($owner, details: ['business' => ['revenue' => '10000000000000.01', 'cash_flow' => '-123.45', 'currency' => 'USD', 'reason' => 'PRIVATE-FINANCIAL-CANARY']]);
|
||||
$this->login($owner)->getJson(self::BASE.'/'.$record->id)->assertOk()->assertJsonPath('record.values.private_offer.business.cash_flow', '-123.45');
|
||||
$this->approve($owner, $record);
|
||||
$this->assertSame('private-offer-editorial-v3', $record->fresh()->workflow_context['submission']['contract_version']);
|
||||
$this->assertSame('-123.45', $record->fresh()->workflow_context['submission']['payload']['private_offer']['business']['cash_flow']);
|
||||
$this->login($owner)->postJson(self::BASE.'/'.$record->id.'/guardar', ['etag' => $this->etag($record), 'private_offer' => ['business' => ['reason' => 'Changed']], 'reason' => self::REASON])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_property_fields_are_optional_and_categories_and_unknown_fields_are_enforced(): void
|
||||
{
|
||||
$owner = $this->actor('promoter', 'real_estate');
|
||||
$record = $this->draft($owner, 'real_estate', ['property' => ['transaction_type' => 'lease', 'property_type' => 'house', 'built_area' => '120.50', 'area_unit' => 'sqm', 'bedrooms' => 3, 'bathrooms' => 2, 'amenities' => ['Água', 'Acesso']]]);
|
||||
$this->login($owner)->getJson(self::BASE.'/'.$record->id)->assertOk()->assertJsonPath('record.values.private_offer.property.bedrooms', 3);
|
||||
foreach ([['business' => ['revenue' => '5', 'currency' => 'USD']], ['property' => ['title_certified' => true]], ['property' => ['property_type' => 'invented']]] as $invalid) {
|
||||
$this->postJson(self::BASE.'/'.$record->id.'/guardar', ['etag' => $this->etag($record), 'private_offer' => $invalid, 'reason' => self::REASON])->assertUnprocessable();
|
||||
}
|
||||
$this->assertSame(3, app(OpportunityManagement::class)->values($record->fresh())['private_offer']['property']['bedrooms']);
|
||||
}
|
||||
|
||||
public function test_six_photos_limit_includes_restoration_and_alternate_routes_and_cleans_failed_uploads(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$record = $this->draft($owner);
|
||||
$ids = [];
|
||||
for ($index = 0; $index < 6; $index++) {
|
||||
$response = $this->upload($owner, $record, 'images', $this->png('qa-'.$index.'.png', 400 + $index, 400));
|
||||
$ids[] = $response->assertCreated()->json('id');
|
||||
}
|
||||
$this->upload($owner, $record, 'images', $this->png('seventh.png', 420, 420))->assertUnprocessable();
|
||||
$this->assertDatabaseCount('opportunity_images', 6);
|
||||
$this->assertCount(6, Storage::disk('local')->allFiles('opportunity-resources'));
|
||||
$life = ['etag' => $this->etag($record), 'version' => 1, 'confirm' => true, 'action' => 'delete', 'reason' => self::REASON];
|
||||
$this->postJson(self::BASE.'/'.$record->id.'/recursos/images/'.$ids[0], $life)->assertOk();
|
||||
$this->upload($owner, $record, 'images', $this->png('new.png', 430, 430))->assertCreated();
|
||||
$restore = [...$life, 'etag' => $this->etag($record), 'version' => 2, 'action' => 'restore'];
|
||||
$this->postJson(self::BASE.'/'.$record->id.'/recursos/images/'.$ids[0], $restore)->assertUnprocessable();
|
||||
// The accepted F1 guard closes the native promoter route for private offers.
|
||||
$this->postJson('/minhas-oportunidades/'.$record->id.'/resources/images/'.$ids[0].'/lifecycle', $restore)->assertForbidden();
|
||||
$this->assertSame(6, DB::table('opportunity_images')->whereNull('deleted_at')->count());
|
||||
}
|
||||
|
||||
private function upload(User $owner, Opportunity $record, string $kind, UploadedFile $file, array $override = []): TestResponse
|
||||
{
|
||||
$payload = $kind === 'images' ? ['visibility' => 'internal', 'caption' => 'Synthetic QA', 'digitally_edited' => false] : ['visibility' => 'internal', 'classification' => 'restricted', 'category' => 'real_estate_proof'];
|
||||
|
||||
return $this->login($owner)->post(self::BASE.'/'.$record->id.'/recursos/'.$kind, ['etag' => $this->etag($record), 'creation_key' => (string) Str::uuid(), 'reason' => self::REASON,
|
||||
'payload' => json_encode([...$payload, ...$override]), 'file' => $file], ['Accept' => 'application/json']);
|
||||
}
|
||||
|
||||
/** Valid synthetic PNG bytes, independent of the optional GD extension. */
|
||||
private function png(string $name, int $width, int $height): UploadedFile
|
||||
{
|
||||
$chunk = static fn (string $type, string $bytes): string => pack('N', strlen($bytes)).$type.$bytes.pack('N', crc32($type.$bytes));
|
||||
$pixels = gzcompress(str_repeat("\0".str_repeat("\0", $width * 3), $height));
|
||||
if ($pixels === false) {
|
||||
throw new \RuntimeException('Synthetic PNG compression failed.');
|
||||
}
|
||||
|
||||
return UploadedFile::fake()->createWithContent($name, "\x89PNG\r\n\x1a\n".$chunk('IHDR', pack('NNCCCCC', $width, $height, 8, 2, 0, 0, 0)).$chunk('IDAT', $pixels).$chunk('IEND', ''));
|
||||
}
|
||||
|
||||
public function test_upload_validation_and_quarantine_do_not_grant_publication_or_read_access(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$record = $this->draft($owner);
|
||||
$this->upload($owner, $record, 'images', UploadedFile::fake()->createWithContent('fake.jpg', '<?php echo 1;'))->assertUnprocessable();
|
||||
$this->upload($owner, $record, 'images', $this->png('small.png', 20, 20))->assertUnprocessable();
|
||||
$this->upload($owner, $record, 'images', $this->png('edited.png', 400, 400), ['digitally_edited' => true])->assertUnprocessable();
|
||||
$this->upload($owner, $record, 'images', $this->png('public.png', 400, 400), ['visibility' => 'public'])->assertUnprocessable();
|
||||
$id = $this->upload($owner, $record, 'images', $this->png('good.png', 400, 400))->assertCreated()->json('id');
|
||||
$this->getJson(self::BASE.'/'.$record->id.'/recursos/images/'.$id)->assertNotFound();
|
||||
$this->postJson(self::BASE.'/'.$record->id.'/submeter', ['etag' => $this->etag($record), 'confirm' => true])->assertUnprocessable();
|
||||
$this->assertSame('draft', $record->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_proof_bytes_are_encrypted_and_only_a_scoped_internal_validator_can_read_them(): void
|
||||
{
|
||||
$owner = $this->actor('promoter', 'real_estate');
|
||||
$record = $this->draft($owner, 'real_estate');
|
||||
$bytes = "%PDF-1.7\nSYNTHETIC QA PROPERTY PROOF - NOT A TITLE\n%%EOF";
|
||||
$resourceId = $this->upload($owner, $record, 'documents', UploadedFile::fake()->createWithContent('qa.pdf', $bytes))->assertCreated()->json('id');
|
||||
$link = DB::table('opportunity_documents')->where('id', $resourceId)->first();
|
||||
$doc = DB::table('documents')->where('id', $link->document_id)->first();
|
||||
$this->assertNotSame($bytes, Storage::disk('local')->get($doc->storage_key));
|
||||
(new ScanDocumentForMalware($doc->id))->handle(new CleanScannerFixture);
|
||||
$url = self::BASE.'/'.$record->id.'/recursos/documents/'.$resourceId;
|
||||
$this->login($owner)->get($url)->assertForbidden();
|
||||
$this->get('/minhas-oportunidades/'.$record->id.'/resources/documents/'.$resourceId.'/download')->assertForbidden();
|
||||
$reviewer = $this->actor('validator', 'real_estate');
|
||||
$this->login($reviewer)->get($url)->assertOk()->assertContent($bytes)->assertHeader('Cache-Control', 'no-store, private');
|
||||
$this->login($this->actor('validator', 'business_sale'))->get($url)->assertForbidden();
|
||||
$this->login($this->actor('auditor', 'real_estate'))->get($url)->assertForbidden();
|
||||
$this->login($reviewer)->withSession(['portal.mfa_at' => time() - 1000])->get($url)->assertForbidden();
|
||||
$this->assertGreaterThan(0, DB::table('audit_events')->where('action', 'offer_proof_access_authorized')->count());
|
||||
$this->assertGreaterThan(0, DB::table('audit_events')->where('action', 'offer_proof_access_denied')->count());
|
||||
}
|
||||
|
||||
public function test_nda_absence_keeps_access_closed_and_acceptance_alone_never_grants_access(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$record = $this->draft($owner);
|
||||
$this->approve($owner, $record);
|
||||
$interested = $this->actor();
|
||||
$this->login($interested)->getJson(self::BASE.'/'.$record->id.'/confidencialidade')->assertOk()->assertJsonPath('nda_pending', true)->assertJsonPath('requests', []);
|
||||
$this->getJson(self::BASE.'/'.$record->id.'/confidencial')->assertForbidden();
|
||||
$this->get(self::BASE.'/'.$record->id.'/nda')->assertConflict();
|
||||
$this->postJson(self::BASE.'/'.$record->id.'/confidencialidade', $this->command($record, ['sha256' => str_repeat('0', 64), 'fingerprint' => str_repeat('0', 64)], 'accept'))->assertConflict();
|
||||
$nda = $this->nda();
|
||||
$command = $this->command($record, $nda, 'accept');
|
||||
unset($command['reason']); // Accepting an identified NDA does not require invented justification.
|
||||
$this->postJson(self::BASE.'/'.$record->id.'/confidencialidade', $command)->assertOk();
|
||||
$this->getJson(self::BASE.'/'.$record->id.'/confidencial')->assertForbidden();
|
||||
$this->login($owner)->postJson(self::BASE.'/'.$record->id.'/confidencialidade', $this->command($record, $nda, 'authorize', ['interested_user_id' => $interested->id, 'acceptance_id' => (string) Str::uuid()]))->assertConflict();
|
||||
}
|
||||
|
||||
public function test_both_conditions_exact_versions_revocation_and_audit_are_required_on_every_read(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$record = $this->draft($owner, details: ['business' => ['revenue' => '789.01', 'currency' => 'MZN', 'reason' => 'PRIVATE-FINANCIAL-CANARY']]);
|
||||
$this->approve($owner, $record);
|
||||
$nda = $this->nda();
|
||||
$interested = $this->actor();
|
||||
$url = self::BASE.'/'.$record->id;
|
||||
$this->login($interested)->postJson($url.'/confidencialidade', $this->command($record, $nda, 'accept'))->assertOk();
|
||||
$accept = $this->getJson($url.'/confidencialidade')->json('acceptance');
|
||||
$grant = $this->command($record, $nda, 'authorize', ['interested_user_id' => $interested->id, 'acceptance_id' => $accept['id']]);
|
||||
$this->login($this->actor('validator'))->postJson($url.'/confidencialidade', $grant)->assertForbidden();
|
||||
$this->login($owner)->postJson($url.'/confidencialidade', $grant)->assertOk();
|
||||
$data = $this->login($interested)->getJson($url.'/confidencial')->assertOk()->assertJsonPath('business.revenue', '789.01')->getContent();
|
||||
$this->assertStringNotContainsString('PRIVATE-CONTACT-CANARY', $data);
|
||||
$this->assertStringNotContainsString('PRIVATE-DESCRIPTION-CANARY', $data);
|
||||
$this->login($this->actor())->getJson($url.'/confidencial')->assertForbidden();
|
||||
$this->login($owner)->postJson($url.'/confidencialidade', $this->command($record, $nda, 'revoke', ['interested_user_id' => $interested->id]))->assertOk();
|
||||
$this->login($interested)->getJson($url.'/confidencial')->assertForbidden();
|
||||
$this->assertSame(1, DB::table('audit_events')->where('action', 'offer_confidential_access')->count());
|
||||
$this->assertSame(1, DB::table('audit_events')->where('action', 'offer_confidentiality_revoke')->count());
|
||||
$this->assertNull($record->fresh()->published_at);
|
||||
}
|
||||
|
||||
public function test_nda_version_changes_withdrawal_session_revocation_and_private_revision_close_access(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$record = $this->draft($owner, details: ['business' => ['revenue' => '900.00', 'currency' => 'USD', 'reason' => 'PRIVATE-VERSION-CANARY']]);
|
||||
$this->approve($owner, $record);
|
||||
$nda = $this->nda();
|
||||
$interested = $this->actor();
|
||||
$url = self::BASE.'/'.$record->id;
|
||||
$authorize = function (array $currentNda) use ($owner, $record, $interested, $url) {
|
||||
$this->login($interested)->postJson($url.'/confidencialidade', $this->command($record, $currentNda, 'accept'))->assertOk();
|
||||
$acceptance = $this->getJson($url.'/confidencialidade')->json('acceptance');
|
||||
$this->login($owner)->postJson($url.'/confidencialidade', $this->command($record, $currentNda, 'authorize', ['interested_user_id' => $interested->id, 'acceptance_id' => $acceptance['id']]))->assertOk();
|
||||
$this->login($interested)->getJson($url.'/confidencial')->assertOk();
|
||||
};
|
||||
$authorize($nda);
|
||||
// Identical document bytes in another approved version do not reuse the acceptance.
|
||||
DB::table('documents')->where('id', $nda['document_id'])->update(['version_no' => 2]);
|
||||
config(['private_offers.nda.version' => 2]);
|
||||
$this->getJson($url.'/confidencial')->assertForbidden();
|
||||
$this->postJson($url.'/confidencialidade', $this->command($record, $nda, 'accept'))->assertConflict();
|
||||
$nda = app(PrivateOfferConfidentiality::class)->nda();
|
||||
$authorize($nda);
|
||||
$this->postJson($url.'/confidencialidade', $this->command($record, $nda, 'withdraw'))->assertOk();
|
||||
$this->getJson($url.'/confidencial')->assertForbidden();
|
||||
$authorize($nda);
|
||||
DB::table('users')->where('id', $interested->id)->increment('portal_access_revision');
|
||||
$this->login($interested->fresh())->getJson($url.'/confidencial')->assertForbidden();
|
||||
$authorize($nda);
|
||||
$withdraw = $this->command($record, $nda, 'withdraw');
|
||||
$this->login($owner)->postJson($url.'/revisao', ['etag' => $this->etag($record), 'action' => 'request_revision', 'reason' => self::REASON])->assertOk();
|
||||
$this->login($interested)->getJson($url.'/confidencial')->assertConflict();
|
||||
$this->getJson($url.'/confidencialidade')->assertOk()->assertJsonPath('can_accept', false)->assertJsonPath('etag', null);
|
||||
$this->postJson($url.'/confidencialidade', $withdraw)->assertOk();
|
||||
$this->getJson('/api/v1/offers')->assertOk()->assertJsonPath('results.total', 0);
|
||||
$this->getJson('/api/v1/offers/'.$record->id)->assertNotFound();
|
||||
$this->assertStringNotContainsString('PRIVATE-VERSION-CANARY', $this->get('/api/v1/offers/export')->assertOk()->streamedContent());
|
||||
$this->getJson('/api/v1/offers/map')->assertOk()->assertJsonPath('features', []);
|
||||
$this->assertSame(2, DB::table('audit_events')->where('action', 'offer_confidentiality_withdraw')->count());
|
||||
}
|
||||
|
||||
public function test_nda_integrity_and_authority_loss_are_rechecked_without_exposing_the_company(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$record = $this->draft($owner);
|
||||
$this->approve($owner, $record);
|
||||
$interested = $this->actor();
|
||||
$nda = $this->nda();
|
||||
$url = self::BASE.'/'.$record->id;
|
||||
$this->login($interested)->get($url.'/nda')->assertOk()->assertSee('SYNTHETIC QA - NOT A LEGAL AGREEMENT');
|
||||
$this->postJson($url.'/confidencialidade', $this->command($record, $nda, 'accept'))->assertOk();
|
||||
$accept = $this->getJson($url.'/confidencialidade')->json('acceptance');
|
||||
$this->login($owner)->postJson($url.'/confidencialidade', $this->command($record, $nda, 'authorize', ['interested_user_id' => $interested->id, 'acceptance_id' => $accept['id']]))->assertOk();
|
||||
$this->login($interested)->getJson($url.'/confidencial')->assertOk();
|
||||
DB::table('portal_role_assignments')->where('user_id', $owner->id)->where('role', 'promoter')->update(['revoked_at' => now()]);
|
||||
$this->getJson($url.'/confidencial')->assertForbidden();
|
||||
// Restore this synthetic fixture only, to isolate the byte-integrity check below.
|
||||
DB::table('portal_role_assignments')->where('user_id', $owner->id)->where('role', 'promoter')->update(['revoked_at' => null]);
|
||||
$doc = DB::table('documents')->where('id', $nda['document_id'])->first();
|
||||
Storage::disk('local')->put($doc->storage_key, Crypt::encryptString('TAMPERED SYNTHETIC BYTES'));
|
||||
$this->getJson($url.'/confidencial')->assertForbidden();
|
||||
$this->getJson($url.'/confidencialidade')->assertOk()->assertJsonPath('nda_pending', true);
|
||||
$this->assertNull($record->fresh()->published_at);
|
||||
}
|
||||
|
||||
public function test_compiled_confidential_page_has_no_company_data_before_both_conditions(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$record = $this->draft($owner, details: ['business' => ['reason' => 'PRIVATE-HTML-CANARY']]);
|
||||
$this->approve($owner, $record);
|
||||
$directory = getenv('EDITORIAL_QA_BUILD_DIRECTORY') ?: 'build';
|
||||
$manifest = json_decode(file_get_contents(public_path($directory.'/manifest.json')), true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->app->instance(Vite::class, (new Vite)->useBuildDirectory($directory)->useHotFile(storage_path('phpunit-offers-no-hot')));
|
||||
$entry = 'resources/js/pages/offers/confidentiality.tsx';
|
||||
$this->assertArrayHasKey($entry, $manifest);
|
||||
$this->login($this->actor())->get(self::BASE.'/'.$record->id.'/confidencialidade', ['Accept' => 'text/html'])->assertOk()
|
||||
->assertHeader('Cache-Control', 'no-store, private')->assertSee($manifest[$entry]['file'], false)->assertDontSee('PRIVATE-HTML-CANARY')->assertDontSee('PRIVATE-CONTACT-CANARY');
|
||||
}
|
||||
|
||||
public function test_photo_metadata_and_original_are_bound_to_the_editorial_fingerprint(): void
|
||||
{
|
||||
$owner = $this->actor('promoter');
|
||||
$record = $this->draft($owner);
|
||||
$resourceId = $this->upload($owner, $record, 'images', $this->png('metadata.png', 400, 400))->assertCreated()->json('id');
|
||||
$resource = DB::table('opportunity_images')->where('id', $resourceId)->first();
|
||||
(new ScanDocumentForMalware($resource->document_id))->handle(new CleanScannerFixture);
|
||||
$before = app(PrivateOfferPreparation::class)->snapshot($record->fresh());
|
||||
$this->postJson(self::BASE.'/'.$record->id.'/recursos/images/'.$resourceId.'/guardar', ['etag' => $this->etag($record), 'version' => 1,
|
||||
'payload' => ['visibility' => 'internal', 'caption' => 'Changed synthetic caption', 'digitally_edited' => true, 'edit_description' => 'Synthetic QA declared edit'], 'reason' => self::REASON])->assertOk();
|
||||
$after = app(PrivateOfferPreparation::class)->snapshot($record->fresh());
|
||||
$this->assertNotSame($before['hash'], $after['hash']);
|
||||
$this->assertSame(1, $before['resources']['images'][$resourceId]['version']);
|
||||
$this->assertSame(2, $after['resources']['images'][$resourceId]['version']);
|
||||
$this->assertSame($resource->document_id, $after['resources']['images'][$resourceId]['document_id']);
|
||||
$this->assertTrue($after['resources']['images'][$resourceId]['payload']['digitally_edited']);
|
||||
$this->approve($owner, $record);
|
||||
DB::table('documents')->where('id', $resource->document_id)->update(['status' => 'quarantined', 'malware_scan_status' => 'pending']);
|
||||
$this->login($owner)->getJson(self::BASE.'/'.$record->id)->assertOk()->assertJsonPath('record.approvalFresh', false);
|
||||
$this->postJson(self::BASE.'/'.$record->id.'/revisao', ['etag' => $this->etag($record), 'action' => 'request_revision', 'reason' => self::REASON])->assertOk()->assertJsonPath('record.state', 'needs_information')->assertJsonPath('record.canEdit', true);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Models\User;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\OpportunityMaturity;
|
||||
use App\Support\IsolatedQaDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class OpportunitySearchFixture
|
||||
@@ -20,9 +21,27 @@ final class OpportunitySearchFixture
|
||||
}
|
||||
$province = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->where('name', 'Inhambane')->first();
|
||||
if (! $province) {
|
||||
throw new \LogicException('QA requires the existing public Inhambane reference');
|
||||
// Tests must not depend on an operational import. These placeholders exist
|
||||
// only in the already isolated test database and caller's rollback transaction.
|
||||
if (DB::transactionLevel() < 1) {
|
||||
throw new \LogicException('Synthetic references require test rollback');
|
||||
}
|
||||
$country = (string) Str::uuid();
|
||||
$id = (string) Str::uuid();
|
||||
foreach ([[$country, 'country', null, 'Synthetic QA country'], [$id, 'province', $country, 'Inhambane']] as [$key, $type, $parent, $name]) {
|
||||
DB::table('geo_locations')->insert(['id' => $key, 'code' => 'SYNTHETIC-QA-'.$key, 'name' => $name,
|
||||
'location_type' => $type, 'parent_id' => $parent, 'data_classification' => 'public', 'source_system' => 'cartografia-workbook',
|
||||
'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$key]);
|
||||
}
|
||||
$province = app(InvestmentGeography::class)->publicReferences()->where('id', $id)->first();
|
||||
}
|
||||
$sector = Sector::where('slug', 'turismo')->first();
|
||||
if (! $sector) {
|
||||
if (DB::transactionLevel() < 1) {
|
||||
throw new \LogicException('Synthetic sector requires test rollback');
|
||||
}
|
||||
$sector = Sector::create(['slug' => 'turismo', 'name' => 'Synthetic QA tourism', 'is_active' => true]);
|
||||
}
|
||||
$sector = Sector::where('slug', 'turismo')->firstOrFail();
|
||||
$editor = User::factory()->create();
|
||||
$reviewer = User::factory()->create();
|
||||
$zone = InvestmentZone::query()->create(['id' => (string) Str::uuid(), 'code' => 'SINT-QA-'.Str::random(8),
|
||||
|
||||
Reference in New Issue
Block a user