withoutVite(); Mail::fake(); config(['portal.profile_access_enabled' => true, 'editorial_workflow.enabled' => true]); } private function actor(string $role, array $responsibilities = [], array $types = ['business_sale']): User { $user = User::factory()->create(); $user->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC-QA'), 'two_factor_confirmed_at' => now()])->save(); $this->grant($user, $role, $responsibilities, $types); return $user; } private function grant(User $user, string $role, array $responsibilities = [], array $types = ['business_sale']): void { app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [ 'user_id' => $user->id, 'role' => $role, 'organization_id' => null, 'responsibilities' => $responsibilities, 'opportunity_types' => $types, 'source' => 'synthetic-test-fixture', ]); } private function login(User $actor): static { 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): array { $contact = (string) Str::uuid(); DB::table('contacts')->insert(['id' => $contact, 'full_name' => 'Contacto sintético QA', 'email' => 'synthetic-'.Str::uuid().'@example.test', 'source_system' => 'portal-user-contact', 'external_id' => 'user:'.$owner->id]); return $this->login($owner)->postJson(self::BASE.'/rascunhos', ['creation_key' => (string) Str::uuid(), 'opportunity_category' => 'business_sale', 'title' => 'Negócio sintético privado', 'description' => 'Oferta sintética para testar o circuito editorial privado.', 'private_offer' => ['contact_id' => $contact, 'price_mode' => 'amount', 'amount' => '12345.67', 'currency' => 'MZN'], 'reason' => self::REASON])->assertCreated()->json('record'); } private function submit(User $actor, int $id): array { return $this->login($actor)->postJson(self::BASE.'/'.$id.'/submeter', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'confirm' => true])->assertOk()->json('record'); } private function action(User $actor, int $id, string $action, array $extra = [], int $status = 200): array { return $this->login($actor)->postJson(self::BASE.'/'.$id.'/revisao', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'action' => $action, 'reason' => self::REASON, ...$extra])->assertStatus($status)->json('record') ?? []; } public function test_submitted_offer_enters_existing_dashboard_and_private_approval_has_exact_snapshot_and_audit(): void { $owner = $this->actor('promoter'); $reviewer = $this->actor('validator', ['validation_coordinator']); $draft = $this->draft($owner); $submitted = $this->submit($owner, $draft['id']); $this->assertSame('submitted', $submitted['state']); $record = Opportunity::findOrFail($draft['id']); $hash = $record->workflow_context['submission']['hash']; $this->assertSame(1, DB::table('tasks')->where('source_system', 'opportunity-workflow')->count()); $this->assertSame(1, app(PortalEditorialWorkflow::class)->opportunityPending($reviewer)); $this->login($reviewer)->getJson('/management/editorial')->assertOk()->assertJsonPath('offersUrl', self::BASE)->assertJsonPath('opportunityPending', 1); $this->get(self::BASE.'?state=submitted')->assertInertia(fn (Assert $page) => $page->has('records.data', 1)->where('records.data.0.id', $record->id)); $review = $this->action($reviewer, $record->id, 'start_review'); $this->assertSame('in_review', $review['state']); $approved = $this->action($reviewer, $record->id, 'approve'); $this->assertSame('approved', $approved['state']); $this->assertSame($hash, $approved['approval']['hash']); $this->assertTrue($approved['approvalFresh']); $this->assertFalse($approved['canEdit']); $this->assertDatabaseHas('opportunities', ['id' => $record->id, 'status' => 'draft', 'published_at' => null]); $this->assertDatabaseCount('portal_editorial_editions', 0); $this->assertSame(0, DB::table('tasks')->where('task_type', 'opportunity_publication')->count()); $audit = DB::table('audit_events')->where('action', 'workflow_approve')->first(); $data = json_decode($audit->after_data, true, 512, JSON_THROW_ON_ERROR); $this->assertSame($hash, $data['decision']['hash']); $this->assertSame($hash, json_decode(Crypt::decryptString($data['snapshot']), true)['hash']); $this->assertSame(0, Opportunity::published()->count()); } public function test_rejection_revision_correction_resubmission_preserve_identity_decisions_and_versions(): void { $owner = $this->actor('promoter'); $reviewer = $this->actor('validator'); $id = $this->draft($owner)['id']; $this->submit($owner, $id); $firstHash = Opportunity::findOrFail($id)->workflow_context['submission']['hash']; $this->action($reviewer, $id, 'start_review'); $rejected = $this->action($reviewer, $id, 'reject'); $this->assertSame('rejected', $rejected['state']); $this->login($owner)->postJson(self::BASE.'/'.$id.'/guardar', ['etag' => $rejected['etag'], 'title' => 'Contorno', 'reason' => self::REASON])->assertForbidden(); $revision = $this->action($owner, $id, 'request_revision'); $this->assertTrue($revision['canEdit']); $saved = $this->login($owner)->postJson(self::BASE.'/'.$id.'/guardar', ['etag' => $revision['etag'], 'title' => 'Oferta sintética corrigida', 'private_offer' => ['price_mode' => 'negotiable', 'amount' => null, 'currency' => null, 'contact_id' => $revision['values']['private_offer']['contact_id']], 'reason' => self::REASON])->assertOk()->json('record'); $this->getJson(self::BASE.'/'.$id)->assertJsonPath('record.values.title', 'Oferta sintética corrigida'); $resubmitted = $this->submit($owner, $id); $this->assertSame(2, Opportunity::findOrFail($id)->workflow_context['review_cycle']); $resubmission = Opportunity::findOrFail($id)->workflow_context; $this->assertSame($resubmission['submission']['record_version'], $resubmission['last_decision']['submitted_revision']); $this->assertNotSame($firstHash, Opportunity::findOrFail($id)->workflow_context['submission']['hash']); $tasks = DB::table('tasks')->where('source_system', 'opportunity-workflow')->count(); $notices = DB::table('portal_editorial_notifications')->count(); $this->login($owner)->postJson(self::BASE.'/'.$id.'/submeter', ['etag' => $resubmitted['etag'], 'confirm' => true])->assertOk(); $this->assertSame($tasks, DB::table('tasks')->where('source_system', 'opportunity-workflow')->count()); $this->assertSame($notices, DB::table('portal_editorial_notifications')->count()); $this->action($reviewer, $id, 'start_review'); $approved = $this->action($reviewer, $id, 'approve'); $this->assertSame(2, count($approved['submittedVersions'])); $this->assertSame(self::REASON, $approved['submittedVersions'][0]['rejection']['reason']); $this->assertSame('Negócio sintético privado', $approved['submittedVersions'][0]['submission']['payload']['title']); $this->assertSame('Oferta sintética corrigida', $approved['submittedVersions'][1]['submission']['payload']['title']); $new = $this->action($owner, $id, 'request_revision'); $this->assertNull($new['approval']); $this->assertNotNull($new['submittedVersions'][1]['approval']); $this->assertSame(1, Opportunity::count()); } public function test_return_requires_reason_and_common_field_and_preserves_fields_on_conflict(): void { $owner = $this->actor('promoter'); $reviewer = $this->actor('validator'); $id = $this->draft($owner)['id']; $this->submit($owner, $id); $this->action($reviewer, $id, 'start_review'); $etag = app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)); DB::table('opportunities')->where('id', $id)->increment('management_version'); $this->action($reviewer, $id, 'approve', ['etag' => $etag], 409); $this->action($reviewer, $id, 'reject', ['reason' => ''], 422); $this->action($reviewer, $id, 'request_correction', ['correction_items' => ['not_a_common_field']], 422); $returned = $this->action($reviewer, $id, 'request_correction', ['correction_items' => ['title']]); $this->assertSame('needs_information', $returned['state']); $this->assertTrue($this->login($owner)->getJson(self::BASE.'/'.$id)->json('record.canEdit')); $this->action($reviewer, $id, 'approve', ['etag' => $etag], 403); $this->assertSame('Negócio sintético privado', Opportunity::findOrFail($id)->title); } public function test_author_contributor_wrong_scope_auditor_and_unassigned_reviewer_cannot_approve(): void { $owner = $this->actor('promoter'); $this->grant($owner, 'validator'); $reviewer = $this->actor('validator'); $other = $this->actor('validator'); $wrongScope = $this->actor('validator', [], ['real_estate']); $auditor = $this->actor('auditor'); $id = $this->draft($owner)['id']; $this->submit($owner, $id); $this->action($owner, $id, 'start_review', [], 403); $this->action($wrongScope, $id, 'start_review', [], 403); $this->action($reviewer, $id, 'start_review'); $this->action($owner, $id, 'approve', [], 403); $this->action($other, $id, 'approve', [], 403); $this->action($auditor, $id, 'approve', [], 403); $context = Opportunity::findOrFail($id)->workflow_context; DB::table('opportunities')->where('id', $id)->update(['workflow_context' => json_encode([...$context, 'contributors' => [...$context['contributors'], $reviewer->id]])]); $this->action($reviewer, $id, 'approve', [], 403); DB::table('opportunities')->where('id', $id)->update(['workflow_context' => json_encode($context)]); $this->login($reviewer)->withSession(['portal.mfa_at' => time() - 1000])->postJson(self::BASE.'/'.$id.'/revisao', [ 'etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'action' => 'approve', 'reason' => self::REASON])->assertForbidden()->assertHeader('X-Portal-Reauthentication', 'required'); DB::table('portal_role_assignments')->where('user_id', $reviewer->id)->update(['revoked_at' => now()]); $this->action($reviewer, $id, 'approve', [], 403); $this->assertNull(Opportunity::findOrFail($id)->workflow_context['approval'] ?? null); } public function test_reference_changes_block_approval_and_signal_reconfirmation_without_overwriting_decision(): void { $owner = $this->actor('promoter'); $reviewer = $this->actor('validator'); $draft = $this->draft($owner); $id = $draft['id']; $this->submit($owner, $id); $this->action($reviewer, $id, 'start_review'); DB::table('contacts')->where('id', $draft['values']['private_offer']['contact_id'])->update(['full_name' => 'Alteração sintética na fonte']); $this->action($reviewer, $id, 'approve', [], 409); $this->action($reviewer, $id, 'request_correction', ['correction_items' => ['contact']]); $this->submit($owner, $id); $this->action($reviewer, $id, 'start_review'); $approved = $this->action($reviewer, $id, 'approve'); DB::table('contacts')->where('id', $draft['values']['private_offer']['contact_id'])->update(['full_name' => 'Segunda alteração sintética']); $this->login($owner)->getJson(self::BASE.'/'.$id)->assertJsonPath('record.approvalFresh', false)->assertJsonPath('record.approval.hash', $approved['approval']['hash']); $this->assertTrue($this->action($owner, $id, 'request_revision')['canEdit']); } public function test_phase_one_submission_is_bound_only_on_explicit_first_review(): void { $owner = $this->actor('promoter'); $reviewer = $this->actor('validator'); config(['editorial_workflow.enabled' => false]); $id = $this->draft($owner)['id']; $this->submit($owner, $id); $phase1 = Opportunity::findOrFail($id)->workflow_context['private_offer_preparation']; config(['editorial_workflow.enabled' => true]); $this->login($reviewer)->getJson(self::BASE.'/'.$id)->assertOk(); $this->assertNull(Opportunity::findOrFail($id)->workflow_context['submission'] ?? null); $this->action($reviewer, $id, 'start_review'); $context = Opportunity::findOrFail($id)->workflow_context; $this->assertSame($phase1, $context['phase1_submission']); $this->assertSame($phase1['submitted_at'], $context['submission']['at']); $this->assertSame($phase1['submitted_by'], $context['submission']['actor_id']); $this->assertSame($phase1['submitted_revision'], $context['submission']['record_version']); $this->assertSame('not_recorded', $context['phase1_binding']['original_reference_fingerprint']); $this->action($reviewer, $id, 'approve'); } public function test_notices_are_scoped_deduplicated_and_use_private_destination_email_rechecks_permission(): void { $owner = $this->actor('promoter'); $reviewer = $this->actor('validator', ['validation_coordinator']); $foreign = $this->actor('validator', ['validation_coordinator'], ['real_estate']); $id = $this->draft($owner)['id']; $this->submit($owner, $id); $workflow = app(PortalEditorialWorkflow::class); $notice = DB::table('portal_editorial_notifications')->where('user_id', $reviewer->id)->first(); $this->assertNotNull($notice); $this->assertSame(0, DB::table('portal_editorial_notifications')->where('user_id', $foreign->id)->count()); $workflow->notifyOpportunity($owner, Opportunity::findOrFail($id), 'submit'); $this->assertSame(1, DB::table('portal_editorial_notifications')->where('user_id', $reviewer->id)->count()); $this->assertSame(self::BASE.'/'.$id, $workflow->notices($reviewer)[0]['href']); config(['portal.notifications_enabled' => true, 'portal.notification_recipients' => [$reviewer->email]]); Mail::swap(new MailManager($this->app)); $transport = Mail::mailer('array')->getSymfonyTransport(); (new NotifyPrivateOfferDecision($notice->id))->handle(); $this->assertCount(1, $transport->messages()); DB::table('portal_role_assignments')->where('user_id', $reviewer->id)->update(['revoked_at' => now()]); $this->assertFalse($workflow->visibleNotice($reviewer, $notice)); (new NotifyPrivateOfferDecision($notice->id))->handle(); $this->assertCount(1, $transport->messages()); $this->login($reviewer)->getJson(self::BASE.'/'.$id)->assertForbidden(); } public function test_publication_and_alternative_routes_remain_blocked_after_private_approval(): void { $owner = $this->actor('promoter'); $reviewer = $this->actor('validator'); $publisher = $this->actor('publisher'); $id = $this->draft($owner)['id']; $this->submit($owner, $id); $this->action($reviewer, $id, 'start_review'); $this->action($reviewer, $id, 'approve'); $this->action($publisher, $id, 'publish', [], 422); $this->login($publisher)->postJson('/minhas-oportunidades/'.$id.'/workflow', ['action' => 'publish'])->assertForbidden(); $this->postJson('/minhas-oportunidades/'.$id.'/divulgacao/confirmar')->assertForbidden(); $record = Opportunity::findOrFail($id); try { app(OpportunityProfileWorkflow::class)->transition($publisher, $id, ['etag' => app(OpportunityManagement::class)->etag($record), 'action' => 'publish', 'reason' => self::REASON]); $this->fail('Private approval was published.'); } catch (HttpException $exception) { $this->assertSame(403, $exception->getStatusCode()); } $this->assertSame(0, Opportunity::published()->count()); $this->getJson('/api/v1/opportunities/'.$record->slug)->assertNotFound(); $this->get('/oportunidades/'.$record->slug)->assertNotFound(); $this->assertDatabaseHas('opportunities', ['id' => $id, 'status' => 'draft', 'published_at' => null]); } public function test_rollback_preserves_draft_and_does_not_leave_tasks_notices_audit_or_send_mail(): void { $owner = $this->actor('promoter'); $reviewer = $this->actor('validator', ['validation_coordinator']); $draft = $this->draft($owner); $auditCount = DB::table('audit_events')->count(); config(['portal.notifications_enabled' => true, 'portal.notification_recipients' => [$reviewer->email]]); Mail::swap(new MailManager($this->app)); $transport = Mail::mailer('array')->getSymfonyTransport(); try { DB::transaction(function () use ($owner, $draft, $transport) { app(PrivateOfferPreparation::class)->submit($owner, $draft['id'], ['etag' => $draft['etag'], 'confirm' => true]); $this->assertCount(0, $transport->messages()); throw new \RuntimeException('synthetic QA rollback'); }); $this->fail('The synthetic transaction did not roll back.'); } catch (\RuntimeException $error) { $this->assertSame('synthetic QA rollback', $error->getMessage()); } $record = Opportunity::findOrFail($draft['id']); $this->assertSame('draft', $record->workflow_context['private_offer_preparation']['state']); $this->assertNull($record->first_submitted_at); $this->assertDatabaseCount('tasks', 0); $this->assertDatabaseCount('portal_editorial_notifications', 0); $this->assertSame($auditCount, DB::table('audit_events')->count()); $this->assertCount(0, $transport->messages()); } public function test_disabling_editorial_does_not_downgrade_a_phase_two_submission_to_phase_one(): void { $owner = $this->actor('promoter'); $reviewer = $this->actor('validator'); $id = $this->draft($owner)['id']; $this->submit($owner, $id); $this->action($reviewer, $id, 'start_review'); $this->action($reviewer, $id, 'request_correction', ['correction_items' => ['title']]); $record = Opportunity::findOrFail($id); $before = $record->getRawOriginal(); config(['editorial_workflow.enabled' => false]); $this->login($owner)->postJson(self::BASE.'/'.$id.'/submeter', ['etag' => app(OpportunityManagement::class)->etag($record), 'confirm' => true])->assertStatus(503); $this->assertSame($before, $record->fresh()->getRawOriginal()); $this->getJson(self::BASE.'/'.$id)->assertOk()->assertJsonPath('record.state', 'needs_information'); } public function test_existing_coordinator_can_reassign_only_to_a_scoped_independent_validator(): void { $owner = $this->actor('promoter'); $reviewer = $this->actor('validator'); $replacement = $this->actor('validator'); $coordinator = $this->actor('validator', ['validation_coordinator']); $wrong = $this->actor('validator', [], ['real_estate']); $id = $this->draft($owner)['id']; $this->submit($owner, $id); $this->action($reviewer, $id, 'start_review'); $this->action($owner, $id, 'assign_review', ['assignee_user_id' => $replacement->id], 403); $this->action($coordinator, $id, 'assign_review', ['assignee_user_id' => $wrong->id], 403); $assigned = $this->action($coordinator, $id, 'assign_review', ['assignee_user_id' => $replacement->id]); $this->assertSame('in_review', $assigned['state']); $this->assertSame($replacement->id, Opportunity::findOrFail($id)->workflow_context['assignment']['assignee_user_id']); $assignmentHistory = Opportunity::findOrFail($id)->workflow_context['assignment_history']; $this->assertCount(2, $assignmentHistory); $this->assertSame($reviewer->id, $assignmentHistory[1]['previous_assignee_user_id']); $this->assertSame($replacement->id, $assignmentHistory[1]['assignee_user_id']); $this->action($reviewer, $id, 'approve', [], 403); $this->assertSame('approved', $this->action($replacement, $id, 'approve')['state']); $this->assertDatabaseCount('opportunities', 1); $this->assertSame(1, DB::table('tasks')->where('source_system', 'opportunity-workflow')->count()); } }