f = admissionFixture(); $this->url = '/requisicoes/'.$this->f['r']->id.'/admissao'; $this->actingAs($this->f['buyer']); }); function admissionAttempt(object $test, array $changes = []): AdmissionDecision { $test->actingAs($test->f['buyer'])->post($test->url.'/decisoes', [...$test->f['proposal'], ...$changes])->assertSessionHasNoErrors(); return AdmissionDecision::query()->latest('id')->firstOrFail(); } function admissionConfer(object $test, AdmissionDecision $decision, ?User $actor = null, string $verdict = 'approved'): void { $test->actingAs($actor ?? $test->f['analyst'])->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => $verdict, 'reason' => 'Conferência exclusivamente fictícia'])->assertSessionHasNoErrors(); } test('formal admission requires independent company quorum and preserves snapshots without financial writes', function () { $decision = admissionAttempt($this); $workflow = app(FormalAdmission::class); expect($workflow->status($decision, $this->f['adoption']))->toBe('pending')->and($workflow->admitted($this->f['r'], $decision->quotation_id))->toBeFalse(); admissionConfer($this, $decision); expect($workflow->status($decision, $this->f['adoption']))->toBe('pending'); admissionConfer($this, $decision, $this->f['other_analyst']); $snapshot = $workflow->selection($this->f['r'], $decision->quotation_id); expect($snapshot['review_ids'])->toHaveCount(2)->and($snapshot['decision_id'])->toBe($decision->id)->and(BudgetEntry::query()->count())->toBe(0); $this->f['analyst']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save(); expect($workflow->selection($this->f['r'], $decision->quotation_id))->toBe($snapshot); $this->get($this->url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $p) => $p->component('purchases/admission')->where('history.data.0.status', 'confirmed')->has('history.data.0.reviews', 2)); }); test('identical conference retries count once and changed retries cannot overwrite', function () { $decision = admissionAttempt($this); admissionConfer($this, $decision); admissionConfer($this, $decision); expect(AdmissionReview::query()->count())->toBe(1)->and(AuditLog::query()->where('action', 'admission.reviewed')->count())->toBe(1); $this->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'returned', 'reason' => 'Outro fundamento fictício'])->assertSessionHasErrors('admission'); expect(AdmissionReview::query()->sole()->verdict)->toBe('approved'); }); test('return ends attempt without excluding and a new attempt starts with no reviews', function () { $decision = admissionAttempt($this); admissionConfer($this, $decision, null, 'returned'); expect(app(FormalAdmission::class)->status($decision, $this->f['adoption']))->toBe('returned')->and($decision->refresh()->outcome)->toBe('admit'); $this->actingAs($this->f['other_analyst'])->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Outra conferência fictícia'])->assertSessionHasErrors('admission'); $next = admissionAttempt($this, ['previous_id' => $decision->id]); expect($next->revision)->toBe(2)->and(AdmissionReview::query()->where('admission_decision_id', $next->id)->count())->toBe(0); $this->post($this->url.'/decisoes', $this->f['proposal'])->assertSessionHasErrors('admission'); }); test('pending divergent and unmet mandatory qualification cannot be admitted', function (string $result) { $answers = $this->f['assessment']['answers']; $answers[0]['result'] = $result; $this->actingAs($this->f['analyst']); app(DocumentaryQualificationWorkflow::class)->assess($this->f['analyst'], $this->f['set'], [...$this->f['assessment'], 'previous_id' => $this->f['record']->id, 'answers' => $answers]); $basis = app(FormalAdmission::class)->basis($this->f['r'], $this->f['quote']->id); $this->actingAs($this->f['buyer'])->post($this->url.'/decisoes', [...$this->f['proposal'], 'basis_hash' => $basis['hash']])->assertSessionHasErrors('admission'); $decision = admissionAttempt($this, ['basis_hash' => $basis['hash'], 'outcome' => 'clarify']); admissionConfer($this, $decision); admissionConfer($this, $decision, $this->f['other_analyst']); expect(app(FormalAdmission::class)->admitted($this->f['r'], $decision->quotation_id))->toBeFalse(); })->with(['pending', 'not_met', 'not_applicable']); test('optional uniform non compliance is permitted but analyst divergence is not', function () { $answers = $this->f['assessment']['answers']; $answers[1]['result'] = 'not_met'; $this->actingAs($this->f['analyst']); app(DocumentaryQualificationWorkflow::class)->assess($this->f['analyst'], $this->f['set'], [...$this->f['assessment'], 'previous_id' => $this->f['record']->id, 'answers' => $answers]); expect(app(FormalAdmission::class)->basis($this->f['r'], $this->f['quote']->id)['clear'])->toBeTrue(); $this->actingAs($this->f['other_analyst']); app(DocumentaryQualificationWorkflow::class)->assess($this->f['other_analyst'], $this->f['set'], $this->f['assessment']); expect(app(FormalAdmission::class)->basis($this->f['r'], $this->f['quote']->id)['clear'])->toBeFalse(); }); test('qualification changes invalidate pending reviews and confirmed selection', function () { $decision = admissionAttempt($this); admissionConfer($this, $decision); $this->f['quote']->update(['reference' => 'Base modificada no teste']); $this->actingAs($this->f['other_analyst'])->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência sobre base antiga'])->assertSessionHasErrors('admission'); expect(app(FormalAdmission::class)->admitted($this->f['r'], $decision->quotation_id))->toBeFalse()->and(AdmissionReview::query()->count())->toBe(1); }); test('new company policy never rewrites a pinned process and new adoption resets decisions', function () { $decision = admissionAttempt($this); admissionConfer($this, $decision); $this->actingAs($this->f['admin']); $workflow = app(FormalAdmission::class); $new = $workflow->policy($this->f['admin'], [...$this->f['policy_data'], 'previous_id' => $this->f['policy']->id, 'required_count' => 1, 'enabled' => false]); $this->actingAs($this->f['buyer'])->post($this->url.'/adoptar', ['policy_id' => $new->id, 'previous_id' => $this->f['adoption']->id, 'reason' => 'Tentativa fictícia desactivada'])->assertSessionHasErrors('admission'); admissionConfer($this, $decision, $this->f['other_analyst']); expect($workflow->admitted($this->f['r'], $decision->quotation_id))->toBeTrue()->and($this->f['adoption']->refresh()->snapshot['required_count'])->toBe(2); $this->actingAs($this->f['admin']); $enabled = $workflow->policy($this->f['admin'], [...$this->f['policy_data'], 'previous_id' => $new->id, 'required_count' => 1]); $this->actingAs($this->f['buyer']); $next = $workflow->adopt($this->f['buyer'], $this->f['r'], $enabled->id, $this->f['adoption']->id, 'Nova versão expressamente adoptada'); expect($next->snapshot['required_count'])->toBe(1)->and($workflow->admitted($this->f['r'], $decision->quotation_id))->toBeFalse()->and(AdmissionDecision::query()->count())->toBe(1); }); test('self confirmation and stale reviewer roles are rejected and prior approvals revalidated', function () { $decision = admissionAttempt($this); $this->f['buyer']->forceFill(['purchasing_role' => PurchasingRole::Finance])->save(); $this->actingAs($this->f['buyer']->fresh())->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Tentativa fictícia de auto aprovação'])->assertForbidden(); admissionConfer($this, $decision); $this->f['analyst']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save(); $this->actingAs($this->f['other_analyst'])->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência com primeiro revogado'])->assertSessionHasErrors('admission'); $this->f['other_analyst']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save(); $this->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'returned', 'reason' => 'Conferente entretanto revogado'])->assertForbidden(); expect(AdmissionReview::query()->count())->toBe(1); }); test('company policy requires explicit values and admin role and optimistic version', function () { $this->post('/politica-admissao', $this->f['policy_data'])->assertForbidden(); $this->actingAs($this->f['admin'])->post('/politica-admissao', ['previous_id' => $this->f['policy']->id, 'enabled' => true, 'roles' => [], 'reason' => 'Configuração sem quórum definido'])->assertSessionHasErrors(['roles', 'required_count']); $this->post('/politica-admissao', $this->f['policy_data'])->assertSessionHasErrors('admission'); $this->get('/politica-admissao')->assertOk()->assertInertia(fn (Assert $p) => $p->component('purchases/admission-policy')->has('policies.data', 1)); expect(AdmissionPolicy::query()->count())->toBe(1); }); test('head department scope requester segregation cross tenant and audit rollback are enforced', function () { $decision = admissionAttempt($this); $this->actingAs($this->f['issuer'])->post($this->url.'/decisoes', $this->f['proposal'])->assertForbidden(); $tenant = Tenant::query()->create(['name' => 'Empresa distinta fictícia']); $outsider = User::factory()->create(['tenant_id' => $tenant->id, 'purchasing_role' => PurchasingRole::Finance]); $this->actingAs($outsider)->get($this->url)->assertNotFound(); $this->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência de outra empresa'])->assertNotFound(); $department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento']); $head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); $this->actingAs($head)->get($this->url)->assertNotFound(); $this->actingAs($this->f['buyer']); $before = AdmissionDecision::query()->count(); DB::statement("CREATE TRIGGER fail_admission_audit BEFORE INSERT ON audit_logs WHEN NEW.action = 'admission.proposed' BEGIN SELECT RAISE(ABORT, 'test audit failure'); END"); try { app(FormalAdmission::class)->propose($this->f['buyer'], $this->f['r'], [...$this->f['proposal'], 'previous_id' => $decision->id]); $this->fail('Missing rollback'); } catch (QueryException) { expect(AdmissionDecision::query()->count())->toBe($before); } finally { DB::statement('DROP TRIGGER fail_admission_audit'); } }); test('active process conflict prevents conference without creating approval', function () { $decision = admissionAttempt($this); ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->f['analyst']->id, 'requisition_id' => $this->f['r']->id, 'has_conflict' => true, 'known_since' => now()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício documentado']); $this->actingAs($this->f['analyst'])->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência bloqueada por conflito'])->assertSessionHasErrors('conflict'); expect(AdmissionReview::query()->count())->toBe(0); }); test('formal task admission follows the adopted quorum and removes only the completed personal intervention', function () { $decision = admissionAttempt($this); $path = '/requisicoes/tarefas?module=purchases&search=admissão'; $this->actingAs($this->f['analyst'])->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1) ->where('tasks.data.0.href', $this->url.'?version='.$this->f['adoption']->id.'&decision_id='.$decision->id.'#admission-'.$decision->id) ->missing('tasks.data.0.snapshot')->missing('tasks.data.0.reason')->missing('tasks.data.0.basis_hash')); $this->get($this->url.'?decision_id='.$decision->id)->assertInertia(fn (Assert $page) => $page->where('focusDecisionId', $decision->id)->where('history.data.0.canReview', true)); // A different company policy does not replace the process's adopted quorum/roles. $this->actingAs($this->f['admin']); app(FormalAdmission::class)->policy($this->f['admin'], [...$this->f['policy_data'], 'previous_id' => $this->f['policy']->id, 'roles' => ['department_head'], 'required_count' => 1]); admissionConfer($this, $decision); $this->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); expect(app(FormalAdmission::class)->status($decision, $this->f['adoption']))->toBe('pending'); $this->actingAs($this->f['other_analyst'])->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)); admissionConfer($this, $decision, $this->f['other_analyst']); $this->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); expect(AdmissionReview::query()->count())->toBe(2)->and(BudgetEntry::query()->count())->toBe(0); }); test('formal task admission retains an older current decision and opens it beyond the first history page', function () { $supplier = $this->f['quote']->supplier->replicate(); $supplier->fill(['nuit' => '987654321', 'name' => 'Segundo fornecedor fictício'])->save(); $quote = $this->f['quote']->replicate(); $quote->fill(['supplier_id' => $supplier->id, 'reference' => 'SEGUNDA-FICTICIA'])->save(); $this->actingAs($this->f['issuer']); $opening = app(ProposalOpeningWorkflow::class); $set = app(DocumentaryQualificationWorkflow::class)->define($this->f['issuer'], $this->f['r'], [...$this->f['definition'], 'previous_id' => $this->f['set']->id, 'basis_hash' => $opening->basis($opening->proposals($this->f['r']))]); $this->actingAs($this->f['analyst']); foreach ([$this->f['quote'], $quote] as $candidate) { app(DocumentaryQualificationWorkflow::class)->assess($this->f['analyst'], $set, [...$this->f['assessment'], 'quotation_id' => $candidate->id]); } $service = app(FormalAdmission::class); $first = admissionAttempt($this, ['basis_hash' => $service->basis($this->f['r'], $this->f['quote']->id)['hash']]); $previous = 0; for ($index = 0; $index < 15; $index++) { $next = admissionAttempt($this, ['quotation_id' => $quote->id, 'previous_id' => $previous, 'basis_hash' => $service->basis($this->f['r'], $quote->id)['hash']]); $previous = $next->id; } $this->actingAs($this->f['other_analyst'])->get($this->url)->assertInertia(fn (Assert $page) => $page->where('history.total', 16)->where('history.data.0.id', $next->id)); $this->get('/requisicoes/tarefas?module=purchases&search=admissão')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 2)->where('tasks.data.0.id', 'admission:'.$first->id.':review')); $this->get($this->url.'?version='.$this->f['adoption']->id.'&decision_id='.$first->id)->assertInertia(fn (Assert $page) => $page->where('history.total', 1)->where('history.data.0.id', $first->id)->where('history.data.0.canReview', true)); }); test('formal task admission respects changed bases conflicts and consultation never records a decision', function () { $decision = admissionAttempt($this); $this->actingAs($this->f['analyst']); $before = [DB::table('admission_reviews')->count(), DB::table('audit_logs')->count(), DB::table('admission_decisions')->get()->toArray()]; $this->get('/requisicoes/tarefas?module=purchases&search=admissão')->assertOk(); $this->get($this->url.'?decision_id='.$decision->id)->assertOk(); expect([DB::table('admission_reviews')->count(), DB::table('audit_logs')->count(), DB::table('admission_decisions')->get()->toArray()])->toEqual($before); ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->f['analyst']->id, 'requisition_id' => $this->f['r']->id, 'has_conflict' => true, 'known_since' => today()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito sintético impeditivo.']); $this->get('/requisicoes/tarefas?module=purchases&search=admissão')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); $this->get($this->url.'?decision_id='.$decision->id)->assertInertia(fn (Assert $page) => $page->where('history.data.0.canReview', false)); $this->f['quote']->update(['reference' => 'BASE-ALTERADA']); expect(app(FormalAdmission::class)->currentReviewBasis($this->f['r'], $this->f['adoption'], $decision))->toBeFalse(); }); test('formal task admission cannot grant review to maintenance or focus another adoption', function () { $decision = admissionAttempt($this); supplyGrant($this->f['analyst'], ['finance']); DB::table('organization_memberships')->where('user_id', $this->f['analyst']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); $this->actingAs($this->f['analyst'])->get('/requisicoes/tarefas?module=purchases&search=admissão')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); $this->get($this->url.'?decision_id='.$decision->id)->assertInertia(fn (Assert $page) => $page->where('history.data.0.canReview', false)->where('canPropose', false)->where('canAdopt', false)); $this->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Não executar em manutenção.'])->assertForbidden(); expect(AdmissionReview::query()->count())->toBe(0); $this->getJson($this->url.'?decision_id=999999')->assertUnprocessable(); });