Files
compras/tests/Feature/ProcurementTest.php
T
nala 48347e2d3a
tests / ci (push) Canceled after 0s
commit5
2026-10-30 10:55:18 +02:00

224 lines
15 KiB
PHP

<?php
use App\Enums\PurchasingRole;
use App\Models\ApprovalStep;
use App\Models\AuditLog;
use App\Models\CostCenter;
use App\Models\Department;
use App\Models\Requisition;
use App\Models\Tenant;
use App\Models\User;
use Inertia\Testing\AssertableInertia as Assert;
function procurementUser(Tenant $tenant, Department $department, PurchasingRole $role = PurchasingRole::Requester): User
{
return User::factory()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => $role]);
}
function procurementPayload(int $center, string $price = '100.00', string $tax = '0'): array
{
return [
'cost_center_id' => $center, 'priority' => 'normal', 'needed_by' => now()->addWeek()->format('Y-m-d'),
'justification' => 'Material necessário para as operações do departamento.',
'items' => [['description' => 'Material', 'category' => 'Consumíveis', 'purchase_type' => 'consumable', 'quantity' => '1', 'unit' => 'un', 'unit_price' => $price, 'tax_rate' => $tax]],
];
}
test('request navigation separates own requests from the complete authorised list', function () {
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id))->assertRedirect();
$admin = procurementUser($this->tenant, $this->department, PurchasingRole::Admin);
$this->actingAs($admin)->post('/requisicoes', procurementPayload($this->center->id))->assertRedirect();
$own = Requisition::query()->where('requester_id', $admin->id)->sole();
$this->get('/requisicoes?view=all')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 2));
$this->get('/requisicoes?view=mine&status=draft')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 1)->where('requisitions.data.0.id', $own->id)->where('filters.view', 'mine'));
$foreign = Tenant::query()->create(['name' => 'Empresa externa']);
$department = Department::query()->create(['tenant_id' => $foreign->id, 'name' => 'Outro departamento']);
$outsider = procurementUser($foreign, $department, PurchasingRole::Admin);
$this->actingAs($outsider)->get('/requisicoes?view=all')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 0));
});
test('approval navigation uses eligibility and keeps personal decisions separate', function () {
$head = procurementUser($this->tenant, $this->department, PurchasingRole::DepartmentHead);
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id))->assertRedirect();
$request = Requisition::query()->sole();
$this->post('/requisicoes/'.$request->id.'/submeter')->assertRedirect();
$this->actingAs($head)->post('/requisicoes', procurementPayload($this->center->id))->assertRedirect();
$own = Requisition::query()->where('requester_id', $head->id)->sole();
$this->post('/requisicoes/'.$own->id.'/submeter')->assertRedirect();
$this->get('/requisicoes?view=approvals')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 1)->where('requisitions.data.0.id', $request->id));
$this->get('/requisicoes?view=decided')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 0));
$this->post('/requisicoes/'.$request->id.'/decidir', ['decision' => 'approved'])->assertRedirect();
$this->get('/requisicoes?view=approvals')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 0));
$this->get('/requisicoes?view=decided')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 1)->where('requisitions.data.0.id', $request->id));
$this->actingAs($this->requester)->get('/requisicoes?view=decided')->assertOk()->assertInertia(fn (Assert $page) => $page->where('requisitions.total', 0));
$this->getJson('/requisicoes?view=unrestricted')->assertUnprocessable();
});
beforeEach(function () {
$this->tenant = Tenant::query()->create(['name' => 'Empresa A']);
$this->department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Operações']);
$this->center = CostCenter::query()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'code' => 'OPS', 'name' => 'Operações']);
$this->requester = procurementUser($this->tenant, $this->department);
});
test('an unassigned account can create an isolated company and becomes its administrator', function () {
$user = User::factory()->create();
$this->actingAs($user)->post('/empresa/iniciar', ['name' => 'Nova empresa', 'department' => 'Geral', 'first_limit' => '1000.00', 'second_limit' => '2000.00', 'includes_tax' => true, 'second_name' => 'Segundo administrador QA', 'second_email' => 'segundo-admin-qa@example.test', 'second_password' => 'Example-password-234!', 'second_password_confirmation' => 'Example-password-234!'])->assertRedirect('/dashboard');
expect($user->refresh()->hasFunctions(['admin']))->toBeTrue()->and($user->purchasing_role)->toBe(PurchasingRole::Configured);
expect($user->tenant_id)->not->toBe($this->tenant->id);
$this->actingAs($user)->post('/empresa/iniciar', ['name' => 'Outra empresa', 'department' => 'Geral'])->assertForbidden();
});
test('server calculates money and ignores forged totals identity status and currency', function () {
$payload = procurementPayload($this->center->id, '0.05', '16');
$payload['items'][0]['quantity'] = '3';
$payload += ['tenant_id' => 999, 'requester_id' => 999, 'total_minor' => 1, 'status' => 'approved', 'currency' => 'USD'];
$this->actingAs($this->requester)->post('/requisicoes', $payload)->assertSessionHasNoErrors()->assertRedirect();
$r = Requisition::query()->firstOrFail();
expect($r->subtotal_minor)->toBe(15)->and($r->tax_minor)->toBe(2)->and($r->total_minor)->toBe(17);
expect($r->requester_id)->toBe($this->requester->id)->and($r->status)->toBe('draft')->and($r->currency)->toBe('MZN');
expect(AuditLog::query()->count())->toBe(1);
});
test('approval boundaries are inclusive and snapshotted', function (string $price, int $steps) {
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id, $price))->assertRedirect();
$r = Requisition::query()->firstOrFail();
$this->post("/requisicoes/{$r->id}/submeter")->assertSessionHasNoErrors()->assertRedirect();
expect($r->refresh()->status)->toBe('pending')->and($r->steps()->count())->toBe($steps);
expect($r->approval_snapshot['first_limit_minor'])->toBe(2500000);
$this->post("/requisicoes/{$r->id}/submeter")->assertForbidden();
expect($r->steps()->count())->toBe($steps);
})->with([['25000.00', 1], ['25000.01', 2], ['150000.00', 2], ['150000.01', 3]]);
test('tax can move a request into a higher approval tier', function () {
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id, '25000.00', '16'));
$r = Requisition::query()->firstOrFail();
$this->post("/requisicoes/{$r->id}/submeter")->assertRedirect();
expect($r->steps()->count())->toBe(2);
});
test('approvals enforce order role department and distinct actors', function () {
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id, '150000.01'));
$r = Requisition::query()->firstOrFail();
$this->post("/requisicoes/{$r->id}/submeter");
$head = procurementUser($this->tenant, $this->department, PurchasingRole::DepartmentHead);
$procurement = procurementUser($this->tenant, $this->department, PurchasingRole::Procurement);
$finance = procurementUser($this->tenant, $this->department, PurchasingRole::Finance);
$admin = procurementUser($this->tenant, $this->department, PurchasingRole::Admin);
$this->actingAs($finance)->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertForbidden();
$this->actingAs($admin)->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertForbidden();
$this->actingAs($head)->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertRedirect();
$head->forceFill(['purchasing_role' => PurchasingRole::Procurement])->save();
$this->actingAs($head)->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertForbidden();
$this->actingAs($procurement)->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertRedirect();
$this->actingAs($finance)->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertRedirect();
expect($r->refresh()->status)->toBe('approved');
$this->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertForbidden();
expect(AuditLog::query()->where('requisition_id', $r->id)->count())->toBe(5);
});
test('requesters cannot approve their own request even as department heads', function () {
$this->requester->forceFill(['purchasing_role' => PurchasingRole::DepartmentHead])->save();
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id));
$r = Requisition::query()->firstOrFail();
$this->post("/requisicoes/{$r->id}/submeter");
$this->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertForbidden();
});
test('a head from another department cannot approve or read the request', function () {
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id));
$r = Requisition::query()->firstOrFail();
$this->post("/requisicoes/{$r->id}/submeter");
$other = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Outro']);
$head = procurementUser($this->tenant, $other, PurchasingRole::DepartmentHead);
$this->actingAs($head)->get("/requisicoes/{$r->id}")->assertNotFound();
$this->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertForbidden();
});
test('rejection requires a reason and ends the approval flow', function () {
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id));
$r = Requisition::query()->firstOrFail();
$this->post("/requisicoes/{$r->id}/submeter");
$this->actingAs(procurementUser($this->tenant, $this->department, PurchasingRole::DepartmentHead));
$this->post("/requisicoes/{$r->id}/decidir", ['decision' => 'rejected'])->assertSessionHasErrors('comment');
$this->post("/requisicoes/{$r->id}/decidir", ['decision' => 'rejected', 'comment' => 'Necessidade não demonstrada.'])->assertRedirect();
expect($r->refresh()->status)->toBe('rejected');
});
test('submitted requests cannot be edited', function () {
$payload = procurementPayload($this->center->id);
$this->actingAs($this->requester)->post('/requisicoes', $payload);
$r = Requisition::query()->firstOrFail();
$this->put("/requisicoes/{$r->id}", $payload)->assertRedirect();
$this->post("/requisicoes/{$r->id}/submeter");
$this->put("/requisicoes/{$r->id}", $payload)->assertForbidden();
});
test('another company cannot list read edit submit or approve a request', function () {
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id));
$r = Requisition::query()->firstOrFail();
$otherTenant = Tenant::query()->create(['name' => 'Empresa B']);
$otherDepartment = Department::query()->create(['tenant_id' => $otherTenant->id, 'name' => 'Geral']);
$otherUser = procurementUser($otherTenant, $otherDepartment, PurchasingRole::Admin);
$this->actingAs($otherUser)->get('/requisicoes')->assertInertia(fn (Assert $page) => $page->has('requisitions.data', 0));
$this->get("/requisicoes/{$r->id}")->assertNotFound();
$this->get("/requisicoes/{$r->id}/editar")->assertNotFound();
$this->post("/requisicoes/{$r->id}/submeter")->assertNotFound();
$this->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertNotFound();
$this->post('/requisicoes', procurementPayload($this->center->id))->assertSessionHasErrors('cost_center_id');
});
test('ordinary users cannot administer the company or assign roles', function () {
$this->actingAs($this->requester)->get('/empresa')->assertForbidden();
$this->post('/empresa/departamentos', ['name' => 'Novo'])->assertForbidden();
$this->post('/empresa/utilizadores', [])->assertForbidden();
});
test('invalid monetary inputs are rejected', function (string $quantity, string $price) {
$payload = procurementPayload($this->center->id, $price);
$payload['items'][0]['quantity'] = $quantity;
$this->actingAs($this->requester)->post('/requisicoes', $payload)->assertSessionHasErrors();
expect(Requisition::query()->count())->toBe(0);
})->with([['0', '10'], ['1', '-10'], ['1', '0.001'], ['0.0001', '10'], ['1e3', '10']]);
test('application audit entries cannot be updated or deleted', function () {
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id));
$audit = AuditLog::query()->firstOrFail();
expect(fn () => $audit->update(['action' => 'changed']))->toThrow(LogicException::class);
expect(fn () => $audit->delete())->toThrow(LogicException::class);
});
test('tenant models fail closed without a signed in user', function () {
expect(Department::query()->count())->toBe(0)->and(CostCenter::query()->count())->toBe(0)->and(ApprovalStep::query()->count())->toBe(0);
});
test('company accounts cannot be deleted through personal settings', function () {
$this->actingAs($this->requester)->delete(route('profile.destroy'), ['password' => 'password'])->assertSessionHasErrors('password');
$this->assertAuthenticatedAs($this->requester);
$this->assertDatabaseHas('users', ['id' => $this->requester->id]);
});
test('administrators can provision team accounts scoped to their company', function () {
$admin = procurementUser($this->tenant, $this->department, PurchasingRole::Admin);
$this->actingAs($admin)->post('/empresa/utilizadores', [
'name' => 'Chefe de teste', 'email' => 'chefe@example.test',
'password' => 'Test-password-1234', 'password_confirmation' => 'Test-password-1234',
'department_id' => $this->department->id, 'purchasing_role' => 'department_head',
'tenant_id' => 999,
])->assertSessionHasNoErrors()->assertRedirect();
$member = User::query()->where('email', 'chefe@example.test')->firstOrFail();
expect($member->tenant_id)->toBe($this->tenant->id)->and($member->purchasing_role)->toBe(PurchasingRole::DepartmentHead);
$this->get('/empresa')->assertInertia(fn (Assert $page) => $page->component('company/index')->has('users', 3));
});
test('requisition pages expose the stored lines and actual approval capabilities', function () {
$this->actingAs($this->requester)->get('/requisicoes/criar')->assertInertia(fn (Assert $page) => $page->component('requisitions/form')->has('costCenters', 1));
$this->post('/requisicoes', procurementPayload($this->center->id));
$r = Requisition::query()->firstOrFail();
$this->get("/requisicoes/{$r->id}")->assertInertia(fn (Assert $page) => $page->component('requisitions/show')->has('requisition.items', 1)->where('canEdit', true)->where('canDecide', false));
$this->post("/requisicoes/{$r->id}/submeter");
$head = procurementUser($this->tenant, $this->department, PurchasingRole::DepartmentHead);
$this->actingAs($head)->get("/requisicoes/{$r->id}")->assertInertia(fn (Assert $page) => $page->where('canEdit', false)->where('canDecide', true)->has('requisition.steps', 1));
$this->get('/dashboard')->assertInertia(fn (Assert $page) => $page->where('stats.awaitingMe', 1)->has('approvals', 1));
});