168 lines
9.7 KiB
PHP
168 lines
9.7 KiB
PHP
<?php
|
|
|
|
use App\Enums\PurchasingRole;
|
|
use App\Models\Budget;
|
|
use App\Models\BudgetEntry;
|
|
use App\Models\BudgetReinforcement;
|
|
use App\Models\User;
|
|
use App\Services\BudgetReinforcementWorkflow;
|
|
use App\Services\BudgetWorkflow;
|
|
use App\Services\RequisitionWorkflow;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
|
|
|
beforeEach(function () {
|
|
$this->f = receiptFixture();
|
|
$this->author = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
|
$this->reviewer = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
|
|
$this->actingAs($this->author);
|
|
$this->budget = Budget::query()->create(['tenant_id' => $this->author->tenant_id, 'cost_center_id' => $this->f['r']->cost_center_id, 'year' => now()->year, 'amount_minor' => 5000, 'includes_tax' => false, 'created_by' => $this->author->id, 'approved_by' => $this->reviewer->id, 'reason' => 'Dotação inicial de teste']);
|
|
$this->data = ['reference' => 'REF TEST 01', 'amount' => '70.25', 'reason' => 'Reforço fictício definido pela empresa de teste'];
|
|
});
|
|
|
|
function proposeTestReinforcement(object $test): BudgetReinforcement
|
|
{
|
|
$test->actingAs($test->author);
|
|
|
|
return app(BudgetReinforcementWorkflow::class)->propose($test->author, $test->budget, $test->data);
|
|
}
|
|
|
|
function approveTestReinforcement(object $test, BudgetReinforcement $reinforcement, string $decision = 'approved'): void
|
|
{
|
|
$test->actingAs($test->reviewer);
|
|
app(BudgetReinforcementWorkflow::class)->review($test->reviewer, $reinforcement, $decision, 'Decisão independente de teste');
|
|
}
|
|
|
|
test('only approved reinforcement increases available budget without rewriting the original', function () {
|
|
$reinforcement = proposeTestReinforcement($this);
|
|
expect($this->budget->approvedTotalMinor())->toBe(5000);
|
|
approveTestReinforcement($this, $reinforcement);
|
|
expect($this->budget->refresh()->amount_minor)->toBe(5000)
|
|
->and($this->budget->includes_tax)->toBeFalse()
|
|
->and($this->budget->approvedTotalMinor())->toBe(12025);
|
|
$this->get('/orcamentos')->assertOk()->assertInertia(fn ($page) => $page->where('budgets.0.available_minor', 12025)->where('budgets.0.reinforced_minor', 7025));
|
|
});
|
|
|
|
test('approved reinforcement enables a previously unaffordable procurement reservation', function () {
|
|
expect(fn () => DB::transaction(fn () => app(BudgetWorkflow::class)->record($this->author, $this->f['r'], 'reserve', 10000, 10000)))->toThrow(ValidationException::class);
|
|
approveTestReinforcement($this, proposeTestReinforcement($this));
|
|
$this->actingAs($this->author);
|
|
DB::transaction(fn () => app(BudgetWorkflow::class)->record($this->author, $this->f['r'], 'reserve', 10000, 10000));
|
|
expect((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(10000);
|
|
});
|
|
|
|
test('reinforcement proposals and approvals are idempotent', function () {
|
|
$first = proposeTestReinforcement($this);
|
|
$this->data['reference'] = ' ref test 01 ';
|
|
expect(proposeTestReinforcement($this)->id)->toBe($first->id);
|
|
approveTestReinforcement($this, $first);
|
|
approveTestReinforcement($this, $first);
|
|
expect(BudgetReinforcement::query()->count())->toBe(1)->and($this->budget->approvedTotalMinor())->toBe(12025);
|
|
});
|
|
|
|
test('duplicate reference with changed values cannot create a second reinforcement', function () {
|
|
proposeTestReinforcement($this);
|
|
$this->data['amount'] = '71.00';
|
|
expect(fn () => proposeTestReinforcement($this))->toThrow(ValidationException::class);
|
|
});
|
|
|
|
test('proposer cannot approve own reinforcement', function () {
|
|
$reinforcement = proposeTestReinforcement($this);
|
|
$this->post('/reforcos/'.$reinforcement->id.'/decidir', ['decision' => 'approved', 'reason' => 'Teste'])->assertForbidden();
|
|
});
|
|
|
|
test('rejection leaves balance unchanged and is final', function () {
|
|
$reinforcement = proposeTestReinforcement($this);
|
|
approveTestReinforcement($this, $reinforcement, 'rejected');
|
|
expect($this->budget->approvedTotalMinor())->toBe(5000);
|
|
expect(fn () => approveTestReinforcement($this, $reinforcement))->toThrow(ValidationException::class);
|
|
});
|
|
|
|
test('reinforcement amount must be positive and technically representable', function (string $amount) {
|
|
$this->data['amount'] = $amount;
|
|
expect(fn () => proposeTestReinforcement($this))->toThrow(ValidationException::class);
|
|
expect(BudgetReinforcement::query()->count())->toBe(0);
|
|
})->with(['0.00', '-1.00', '1000000000000.00']);
|
|
|
|
test('foreign finance cannot inspect or approve another company reinforcement', function () {
|
|
$reinforcement = proposeTestReinforcement($this);
|
|
$other = receiptFixture();
|
|
$user = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
|
$this->actingAs($user)->post('/reforcos/'.$reinforcement->id.'/decidir', ['decision' => 'approved', 'reason' => 'Teste'])->assertNotFound();
|
|
$this->get('/orcamentos')->assertOk()->assertInertia(fn ($page) => $page->has('reinforcements.data', 0));
|
|
});
|
|
|
|
test('warehouse procurement and requesters cannot propose reinforcements', function (PurchasingRole $role) {
|
|
$user = User::factory()->create(['tenant_id' => $this->author->tenant_id, 'purchasing_role' => $role]);
|
|
$this->actingAs($user)->post('/orcamentos/'.$this->budget->id.'/reforcos', $this->data)->assertForbidden();
|
|
})->with([PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Requester, PurchasingRole::DepartmentHead]);
|
|
|
|
test('reinforcement proposal and decision roll back on audit failure', function () {
|
|
$reinforcement = proposeTestReinforcement($this);
|
|
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('audit failed'));
|
|
expect(fn () => approveTestReinforcement($this, $reinforcement))->toThrow(RuntimeException::class);
|
|
expect($reinforcement->refresh()->status)->toBe('pending')->and($this->budget->approvedTotalMinor())->toBe(5000);
|
|
$this->data['reference'] = 'REF TEST 02';
|
|
expect(fn () => proposeTestReinforcement($this))->toThrow(RuntimeException::class);
|
|
expect(BudgetReinforcement::query()->count())->toBe(1);
|
|
});
|
|
|
|
test('reinforcement history cannot be rewritten or deleted', function () {
|
|
$reinforcement = proposeTestReinforcement($this);
|
|
expect(fn () => $reinforcement->update(['amount_minor' => 999]))->toThrow(LogicException::class);
|
|
expect(fn () => $reinforcement->refresh()->delete())->toThrow(LogicException::class);
|
|
});
|
|
|
|
test('review rechecks current author eligibility', function () {
|
|
$reinforcement = proposeTestReinforcement($this);
|
|
$this->author->forceFill(['purchasing_role' => PurchasingRole::Warehouse])->save();
|
|
expect(fn () => approveTestReinforcement($this, $reinforcement))->toThrow(ValidationException::class);
|
|
});
|
|
|
|
test('closed exercise prevents new reinforcement approval', function () {
|
|
$reinforcement = proposeTestReinforcement($this);
|
|
$this->travel(1)->years();
|
|
expect(fn () => approveTestReinforcement($this, $reinforcement))->toThrow(ValidationException::class);
|
|
$this->travelBack();
|
|
});
|
|
|
|
test('unapproved initial allocation cannot receive reinforcement', function () {
|
|
DB::table('budgets')->where('id', $this->budget->id)->update(['approved_by' => null]);
|
|
$this->budget->refresh();
|
|
expect(fn () => proposeTestReinforcement($this))->toThrow(ValidationException::class);
|
|
});
|
|
|
|
test('current operator role is rechecked instead of trusting a stale session profile', function () {
|
|
DB::table('users')->where('id', $this->author->id)->update(['purchasing_role' => 'warehouse']);
|
|
$this->post('/orcamentos/'.$this->budget->id.'/reforcos', $this->data)->assertForbidden();
|
|
});
|
|
|
|
test('browser cannot approve reinforcement or alter the parent allocation base', function () {
|
|
$this->post('/orcamentos/'.$this->budget->id.'/reforcos', [...$this->data, 'status' => 'approved', 'includes_tax' => true, 'tenant_id' => PHP_INT_MAX, 'budget_id' => PHP_INT_MAX])->assertRedirect();
|
|
expect(BudgetReinforcement::query()->firstOrFail()->status)->toBe('pending')
|
|
->and($this->budget->refresh()->includes_tax)->toBeFalse()
|
|
->and($this->budget->approvedTotalMinor())->toBe(5000);
|
|
});
|
|
|
|
test('different companies may use their own identical reinforcement references and different amounts', function () {
|
|
proposeTestReinforcement($this);
|
|
$other = receiptFixture();
|
|
$author = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
|
$reviewer = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
|
|
$this->actingAs($author);
|
|
$budget = Budget::query()->create(['tenant_id' => $author->tenant_id, 'cost_center_id' => $other['r']->cost_center_id, 'year' => now()->year, 'amount_minor' => 2000, 'includes_tax' => true, 'created_by' => $author->id, 'approved_by' => $reviewer->id, 'reason' => 'Outra decisão fictícia']);
|
|
$reinforcement = app(BudgetReinforcementWorkflow::class)->propose($author, $budget, [...$this->data, 'amount' => '10.01']);
|
|
$this->actingAs($reviewer);
|
|
app(BudgetReinforcementWorkflow::class)->review($reviewer, $reinforcement, 'approved', 'Outra decisão independente');
|
|
expect($budget->approvedTotalMinor())->toBe(3001);
|
|
$this->actingAs($this->author);
|
|
expect($this->budget->approvedTotalMinor())->toBe(5000);
|
|
});
|
|
|
|
test('public reinforcement example contains no company allocations', function () {
|
|
auth()->forgetGuards();
|
|
$this->get('/consulta/reforcos')->assertOk()->assertInertia(fn ($page) => $page->where('initialScreen', 'reinforcements')->where('auth.user', null)->missing('budgets')->missing('reinforcements'));
|
|
});
|