Files
Investidor-V2/tests/Feature/PrivateOfferEditorialTest.php
T

357 lines
22 KiB
PHP

<?php
namespace Tests\Feature;
use App\Jobs\NotifyPrivateOfferDecision;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityManagement;
use App\Services\OpportunityProfileWorkflow;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAdministration;
use App\Services\PrivateOfferPreparation;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Mail\MailManager;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
final class PrivateOfferEditorialTest extends TestCase
{
use RefreshDatabase;
private const BASE = '/minhas-oportunidades/ofertas';
private const REASON = 'Decisão fundamentada exclusivamente com dados sintéticos QA.';
protected function setUp(): void
{
parent::setUp();
$this->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());
}
}