169 lines
12 KiB
PHP
169 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Enums\PurchasingRole;
|
|
use App\Models\AdmissionDecision;
|
|
use App\Models\AdmissionPolicy;
|
|
use App\Models\AdmissionReview;
|
|
use App\Models\AuditLog;
|
|
use App\Models\BudgetEntry;
|
|
use App\Models\ConflictDeclaration;
|
|
use App\Models\Department;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\DocumentaryQualificationWorkflow;
|
|
use App\Services\FormalAdmission;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Inertia\Testing\AssertableInertia as Assert;
|
|
|
|
require_once __DIR__.'/../Support/AdmissionFixture.php';
|
|
|
|
beforeEach(function () {
|
|
$this->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);
|
|
});
|