@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ConflictDeclaration;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Department;
|
||||
use App\Models\ProposalEvaluation;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
function personalConflict(int $supplierId, ?int $requisitionId = null, bool $hasConflict = true): array
|
||||
{
|
||||
return ['scope' => $requisitionId ? 'process_supplier' : 'supplier', 'supplier_id' => $supplierId, 'requisition_id' => $requisitionId, 'has_conflict' => $hasConflict, 'known_since' => now()->subYears(3)->format('Y-m-d'), 'reason' => 'Relação pessoal relevante declarada para efeitos de transparência.'];
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::query()->create(['name' => 'Conflitos 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']);
|
||||
foreach (['owner' => PurchasingRole::Requester, 'buyer' => PurchasingRole::Procurement, 'head' => PurchasingRole::DepartmentHead, 'finance' => PurchasingRole::Finance, 'admin' => PurchasingRole::Admin] as $key => $role) {
|
||||
$this->{$key} = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => $role]);
|
||||
}
|
||||
$this->actingAs($this->buyer);
|
||||
$supplierData = ['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'name' => 'Fornecedor A', 'nuit' => '123456789', 'contact' => 'Contacto', 'email' => 'contacto@example.test', 'phone' => '123456789', 'address' => 'Maputo', 'category' => 'Material', 'beneficiaries' => 'Beneficiário de teste', 'document_references' => 'Referências documentais do processo.', 'diligence_notes' => 'Documentação de teste conferida.', 'status' => 'active', 'next_review' => now()->addYear()];
|
||||
$this->supplier = Supplier::query()->create($supplierData);
|
||||
$this->otherSupplier = Supplier::query()->create([...$supplierData, 'name' => 'Fornecedor B', 'nuit' => '987654321']);
|
||||
$this->r = Requisition::query()->create(['tenant_id' => $this->tenant->id, 'requester_id' => $this->owner->id, 'department_id' => $this->department->id, 'cost_center_id' => $this->center->id, 'number' => 'REQ-CONFLICT', 'priority' => 'normal', 'needed_by' => now()->addWeek(), 'justification' => 'Pedido de teste de conflitos.', 'status' => 'approved', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000]);
|
||||
$this->item = $this->r->items()->create(['tenant_id' => $this->tenant->id, 'description' => 'Material', 'category' => 'Material', 'purchase_type' => 'consumable', 'quantity' => '1', 'unit' => 'un', 'unit_price_minor' => 10000, 'tax_rate' => '0', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000]);
|
||||
$this->quote = ['supplier_id' => $this->supplier->id, 'reference' => 'PROP-CONFLICT', 'delivery_days' => 5, 'valid_until' => now()->addMonth()->format('Y-m-d'), 'payment_terms' => '30 dias', 'no_conflict' => true, 'items' => [['requisition_item_id' => $this->item->id, 'unit_price' => '100.00']]];
|
||||
$this->evaluation = ['quality' => '8', 'history' => '5', 'payment' => '7', 'comment' => 'Avaliação independente para testes.', 'no_conflict' => true];
|
||||
});
|
||||
|
||||
test('every purchasing profile can make a personal declaration with server controlled authorship', function (PurchasingRole $role) {
|
||||
$user = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => $role]);
|
||||
$this->actingAs($user)->post('/conflitos', personalConflict($this->supplier->id))->assertSessionHasNoErrors()->assertRedirect('/conflitos');
|
||||
$declaration = ConflictDeclaration::query()->firstOrFail();
|
||||
expect($declaration->user_id)->toBe($user->id)->and($declaration->tenant_id)->toBe($this->tenant->id);
|
||||
expect($declaration->declared_at)->not->toBeNull();
|
||||
expect(AuditLog::query()->where('action', 'conflict.declared')->count())->toBe(1);
|
||||
$this->post('/conflitos', [...personalConflict($this->supplier->id), 'user_id' => $this->owner->id])->assertSessionHasErrors('user_id');
|
||||
})->with(PurchasingRole::cases());
|
||||
|
||||
test('consultation is limited to personal declarations except finance and administration oversight', function (PurchasingRole $role, int $expected) {
|
||||
$this->actingAs($this->owner)->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$declaration = ConflictDeclaration::query()->firstOrFail();
|
||||
$user = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => $role]);
|
||||
$this->actingAs($user)->get('/conflitos')->assertInertia(fn (Assert $page) => $page->component('conflicts/index')->has('declarations.data', $expected)->missing('suppliers.0.beneficiaries')->missing('suppliers.0.email'));
|
||||
if ($expected === 1) {
|
||||
$this->get('/conflitos/'.$declaration->id)->assertInertia(fn (Assert $page) => $page->component('conflicts/show')->where('canCorrect', false));
|
||||
} else {
|
||||
$this->get('/conflitos/'.$declaration->id)->assertNotFound();
|
||||
}
|
||||
$this->put('/conflitos/'.$declaration->id, ['reason' => 'Tentativa de corrigir declaração alheia.', 'known_since' => now()->format('Y-m-d')])->assertForbidden();
|
||||
})->with([[PurchasingRole::Admin, 1], [PurchasingRole::Finance, 1], [PurchasingRole::Procurement, 0], [PurchasingRole::DepartmentHead, 0], [PurchasingRole::Requester, 0], [PurchasingRole::Warehouse, 0]]);
|
||||
|
||||
test('corrections append history preserve the original assertion and reject stale edits', function () {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$original = ConflictDeclaration::query()->firstOrFail();
|
||||
$before = $original->toArray();
|
||||
$correction = ['reason' => 'Fundamento corrigido com informação complementar.', 'known_since' => now()->subYear()->format('Y-m-d')];
|
||||
$this->put('/conflitos/'.$original->id, $correction)->assertSessionHasNoErrors();
|
||||
expect($original->fresh()->toArray())->toBe($before);
|
||||
$latest = ConflictDeclaration::query()->latest('id')->firstOrFail();
|
||||
expect($latest->previous_id)->toBe($original->id)->and($latest->root_id)->toBe($original->id)->and($latest->has_conflict)->toBeTrue()->and($latest->user_id)->toBe($this->buyer->id);
|
||||
$audit = AuditLog::query()->where('action', 'conflict.corrected')->firstOrFail();
|
||||
expect($audit->before['reason'])->toBe($before['reason'])->and($audit->after['reason'])->toBe($correction['reason']);
|
||||
$this->put('/conflitos/'.$original->id, $correction)->assertSessionHasErrors('history');
|
||||
$this->get('/conflitos/'.$original->id)->assertInertia(fn (Assert $page) => $page->has('history', 2)->where('canCorrect', true));
|
||||
$this->put('/conflitos/'.$latest->id, [...$correction, 'reason' => 'Segunda correcção pessoal do fundamento.'])->assertSessionHasNoErrors();
|
||||
$this->get('/conflitos/'.$original->id)->assertInertia(fn (Assert $page) => $page->has('history', 3));
|
||||
});
|
||||
|
||||
test('corrections cannot remove conflicts change scope author or company', function (string $field, mixed $value) {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$declaration = ConflictDeclaration::query()->firstOrFail();
|
||||
$this->put('/conflitos/'.$declaration->id, ['reason' => 'Tentativa de alterar campos protegidos.', 'known_since' => now()->format('Y-m-d'), $field => $value])->assertSessionHasErrors($field);
|
||||
expect(ConflictDeclaration::query()->count())->toBe(1);
|
||||
})->with([['has_conflict', false], ['supplier_id', 999], ['requisition_id', 999], ['user_id', 999], ['tenant_id', 999], ['declared_at', '2020-01-01'], ['previous_id', 999], ['root_id', 999]]);
|
||||
|
||||
test('declaration dates describe knowledge without automatic expiry and absence cannot override a conflict', function () {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id))->assertSessionHasNoErrors();
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id, null, false))->assertSessionHasNoErrors();
|
||||
$this->post('/conflitos', [...personalConflict($this->supplier->id), 'known_since' => now()->addDay()->format('Y-m-d')])->assertSessionHasErrors('known_since');
|
||||
expect(app(ConflictGuard::class)->hasConflict($this->buyer->id, $this->supplier->id, $this->r->id))->toBeTrue();
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote)->assertSessionHasErrors('conflict');
|
||||
expect(Quotation::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('absence alone does not prevent legitimate participation', function () {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id, null, false));
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote)->assertSessionHasNoErrors();
|
||||
expect(Quotation::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('unknown supplier does not block a request but association rechecks its requester', function () {
|
||||
$this->actingAs($this->owner)->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$payload = ['cost_center_id' => $this->center->id, 'priority' => 'normal', 'needed_by' => now()->addWeek()->format('Y-m-d'), 'justification' => 'Requisição criada sem fornecedor definido.', 'items' => [['description' => 'Material', 'category' => 'Material', 'purchase_type' => 'consumable', 'quantity' => '1', 'unit' => 'un', 'unit_price' => '100', 'tax_rate' => '0']]];
|
||||
$this->post('/requisicoes', $payload)->assertSessionHasNoErrors();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$this->put('/requisicoes/'.$r->id, $payload)->assertSessionHasNoErrors();
|
||||
$this->post('/requisicoes/'.$r->id.'/submeter')->assertSessionHasNoErrors();
|
||||
$this->actingAs($this->head)->post('/requisicoes/'.$r->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
|
||||
expect($r->refresh()->status)->toBe('approved');
|
||||
$quote = [...$this->quote, 'items' => [['requisition_item_id' => $r->items()->firstOrFail()->id, 'unit_price' => '100']]];
|
||||
$this->actingAs($this->buyer)->post('/requisicoes/'.$r->id.'/cotacoes', $quote)->assertSessionHasErrors('conflict');
|
||||
$this->post('/requisicoes/'.$r->id.'/cotacoes', [...$quote, 'supplier_id' => $this->otherSupplier->id])->assertSessionHasNoErrors();
|
||||
});
|
||||
|
||||
test('supplier association checks prior approvers even if their role has changed', function () {
|
||||
$this->r->steps()->create(['tenant_id' => $this->tenant->id, 'position' => 1, 'role' => 'department_head', 'decision' => 'approved', 'approver_id' => $this->head->id]);
|
||||
$this->actingAs($this->head)->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$this->head->forceFill(['purchasing_role' => PurchasingRole::Warehouse])->save();
|
||||
$this->actingAs($this->buyer)->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote)->assertSessionHasErrors('conflict');
|
||||
expect(Quotation::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('new associations recheck earlier quotation authors and evaluators', function (string $participant) {
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', [...$this->quote, 'supplier_id' => $this->otherSupplier->id])->assertSessionHasNoErrors();
|
||||
$quote = Quotation::query()->firstOrFail();
|
||||
$actor = $participant === 'author' ? $this->buyer : $this->finance;
|
||||
if ($participant === 'evaluator') {
|
||||
$this->actingAs($actor)->post('/cotacoes/'.$quote->id.'/avaliar', $this->evaluation)->assertSessionHasNoErrors();
|
||||
}
|
||||
$this->actingAs($actor)->post('/conflitos', personalConflict($this->supplier->id))->assertSessionHasNoErrors();
|
||||
$newBuyer = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
$this->actingAs($newBuyer)->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote)->assertSessionHasErrors('conflict');
|
||||
expect(Quotation::query()->count())->toBe(1);
|
||||
})->with(['author', 'evaluator']);
|
||||
|
||||
test('historical audit participants are rechecked at supplier association', function () {
|
||||
$formerParticipant = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Requester]);
|
||||
$this->actingAs($formerParticipant)->post('/conflitos', personalConflict($this->supplier->id));
|
||||
AuditLog::query()->create(['tenant_id' => $this->tenant->id, 'user_id' => $formerParticipant->id, 'requisition_id' => $this->r->id, 'action' => 'requisition.updated', 'after' => ['status' => 'draft']]);
|
||||
$this->actingAs($this->buyer)->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote)->assertSessionHasErrors('conflict');
|
||||
});
|
||||
|
||||
test('process supplier declarations only affect their declared process and supplier', function () {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id, $this->r->id))->assertSessionHasNoErrors();
|
||||
$guard = app(ConflictGuard::class);
|
||||
expect($guard->hasConflict($this->buyer->id, $this->supplier->id, $this->r->id))->toBeTrue();
|
||||
expect($guard->hasConflict($this->buyer->id, $this->otherSupplier->id, $this->r->id))->toBeFalse();
|
||||
expect($guard->hasConflict($this->buyer->id, $this->supplier->id, $this->r->id + 1))->toBeFalse();
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', [...$this->quote, 'supplier_id' => $this->otherSupplier->id])->assertSessionHasNoErrors();
|
||||
});
|
||||
|
||||
test('a process wide conflict blocks participation regardless of candidate supplier', function () {
|
||||
$this->post('/conflitos', [...personalConflict($this->supplier->id, $this->r->id), 'scope' => 'process', 'supplier_id' => null])->assertSessionHasNoErrors();
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote)->assertSessionHasErrors('conflict');
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', [...$this->quote, 'supplier_id' => $this->otherSupplier->id])->assertSessionHasErrors('conflict');
|
||||
});
|
||||
|
||||
test('process declarations only refer to processes visible to their author', function () {
|
||||
$outsider = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Requester]);
|
||||
$this->actingAs($outsider)->get('/conflitos')->assertInertia(fn (Assert $page) => $page->has('requisitions', 0));
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id, $this->r->id))->assertNotFound();
|
||||
});
|
||||
|
||||
test('direct evaluations cannot bypass active conflict with an absence checkbox', function () {
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote);
|
||||
$q = Quotation::query()->firstOrFail();
|
||||
$this->actingAs($this->finance)->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$this->post('/cotacoes/'.$q->id.'/avaliar', $this->evaluation)->assertSessionHasErrors('conflict');
|
||||
expect(ProposalEvaluation::query()->count())->toBe(0);
|
||||
$this->get('/requisicoes/'.$this->r->id.'/cotacoes')->assertInertia(fn (Assert $page) => $page->where('quotations.0.conflict_blocked', true)->where('canEvaluate', false));
|
||||
});
|
||||
|
||||
test('late conflicts keep past evaluations and invalidate comparison readiness', function () {
|
||||
$supplierData = $this->supplier->only(['tenant_id', 'created_by', 'contact', 'email', 'phone', 'address', 'category', 'beneficiaries', 'document_references', 'diligence_notes', 'status', 'next_review']);
|
||||
$third = Supplier::query()->create([...$supplierData, 'name' => 'Fornecedor C', 'nuit' => '111222333']);
|
||||
foreach ([$this->supplier, $this->otherSupplier, $third] as $supplier) {
|
||||
$this->actingAs($this->buyer)->post('/requisicoes/'.$this->r->id.'/cotacoes', [...$this->quote, 'supplier_id' => $supplier->id])->assertSessionHasNoErrors();
|
||||
$q = Quotation::query()->latest('id')->firstOrFail();
|
||||
$this->post('/cotacoes/'.$q->id.'/avaliar', $this->evaluation)->assertSessionHasNoErrors();
|
||||
$this->actingAs($this->finance)->post('/cotacoes/'.$q->id.'/avaliar', $this->evaluation)->assertSessionHasNoErrors();
|
||||
}
|
||||
$this->get('/requisicoes/'.$this->r->id.'/cotacoes')->assertInertia(fn (Assert $page) => $page->where('ready', true));
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id))->assertSessionHasNoErrors();
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id, null, false))->assertSessionHasNoErrors();
|
||||
$this->get('/requisicoes/'.$this->r->id.'/cotacoes')->assertInertia(fn (Assert $page) => $page->where('ready', false)->where('quotations.0.eligible', false)->where('quotations.0.score', null)->has('quotations.0.evaluations', 2));
|
||||
expect(ProposalEvaluation::query()->count())->toBe(6);
|
||||
expect(AuditLog::query()->where('action', 'quotation.evaluated')->count())->toBe(6);
|
||||
});
|
||||
|
||||
test('known supplier conflicts block pending approvals on the server', function () {
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote);
|
||||
$this->r->update(['status' => 'pending']);
|
||||
$this->r->steps()->create(['tenant_id' => $this->tenant->id, 'position' => 1, 'role' => 'department_head']);
|
||||
$this->actingAs($this->head)->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$this->get('/requisicoes/'.$this->r->id)->assertInertia(fn (Assert $page) => $page->where('canDecide', false)->where('conflictBlocked', true));
|
||||
$this->post('/requisicoes/'.$this->r->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
|
||||
expect($this->r->refresh()->status)->toBe('pending');
|
||||
expect($this->r->steps()->firstOrFail()->decision)->toBe('pending');
|
||||
});
|
||||
|
||||
test('a conflict declared by an earlier requester blocks subsequent approval by a different person', function () {
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote)->assertSessionHasNoErrors();
|
||||
$this->r->update(['status' => 'pending']);
|
||||
$this->r->steps()->create(['tenant_id' => $this->tenant->id, 'position' => 1, 'role' => 'department_head']);
|
||||
$this->actingAs($this->owner)->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$this->actingAs($this->head)->post('/requisicoes/'.$this->r->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
|
||||
expect($this->r->steps()->firstOrFail()->decision)->toBe('pending');
|
||||
});
|
||||
|
||||
test('scope requires the corresponding existing supplier and process', function () {
|
||||
$this->post('/conflitos', [...personalConflict($this->supplier->id), 'supplier_id' => null])->assertSessionHasErrors('supplier_id');
|
||||
$this->post('/conflitos', [...personalConflict($this->supplier->id), 'scope' => 'process', 'supplier_id' => null])->assertSessionHasErrors('requisition_id');
|
||||
$this->post('/conflitos', [...personalConflict($this->supplier->id, $this->r->id), 'scope' => 'supplier'])->assertSessionHasErrors('requisition_id');
|
||||
expect(ConflictDeclaration::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('correction audit failure preserves the original and allows a later valid correction', function () {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$declaration = ConflictDeclaration::query()->firstOrFail();
|
||||
$this->mock(RequisitionWorkflow::class, function ($mock) {
|
||||
$mock->shouldReceive('audit')->once()->andThrow(new RuntimeException('Falha de auditoria da correcção.'));
|
||||
});
|
||||
$this->put('/conflitos/'.$declaration->id, ['reason' => 'Correcção que não pode ficar parcialmente gravada.', 'known_since' => now()->format('Y-m-d')])->assertStatus(500);
|
||||
expect(ConflictDeclaration::query()->count())->toBe(1)->and($declaration->successor()->exists())->toBeFalse();
|
||||
expect(AuditLog::query()->where('action', 'conflict.corrected')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('process conflict blocks submission and editing without erasing the draft', function () {
|
||||
$this->r->update(['status' => 'draft']);
|
||||
$this->actingAs($this->owner)->post('/conflitos', [...personalConflict($this->supplier->id, $this->r->id), 'scope' => 'process', 'supplier_id' => null]);
|
||||
$this->post('/requisicoes/'.$this->r->id.'/submeter')->assertSessionHasErrors('conflict');
|
||||
expect($this->r->refresh()->status)->toBe('draft')->and($this->r->items()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('declarations and all changes isolate companies', function () {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id, $this->r->id));
|
||||
$declaration = ConflictDeclaration::query()->firstOrFail();
|
||||
$other = Tenant::query()->create(['name' => 'Conflitos B']);
|
||||
$admin = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$this->actingAs($admin)->get('/conflitos')->assertInertia(fn (Assert $page) => $page->has('declarations.data', 0)->has('suppliers', 0)->has('requisitions', 0));
|
||||
$this->get('/conflitos/'.$declaration->id)->assertNotFound();
|
||||
$this->put('/conflitos/'.$declaration->id, ['reason' => 'Tentativa de outro inquilino.', 'known_since' => now()->format('Y-m-d')])->assertNotFound();
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id))->assertSessionHasErrors('supplier_id');
|
||||
$this->post('/conflitos', [...personalConflict($this->supplier->id, $this->r->id), 'scope' => 'process', 'supplier_id' => null])->assertSessionHasErrors('requisition_id');
|
||||
});
|
||||
|
||||
test('failed audit rolls back declaration and creates no partial blocking record', function () {
|
||||
$this->mock(RequisitionWorkflow::class, function ($mock) {
|
||||
$mock->shouldReceive('audit')->once()->andThrow(new RuntimeException('Falha de auditoria simulada.'));
|
||||
});
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id))->assertStatus(500);
|
||||
expect(ConflictDeclaration::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('declaration model rejects destructive mutation and deletion', function () {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$declaration = ConflictDeclaration::query()->firstOrFail();
|
||||
expect(fn () => $declaration->update(['has_conflict' => false]))->toThrow(LogicException::class);
|
||||
expect(fn () => $declaration->delete())->toThrow(LogicException::class);
|
||||
});
|
||||
|
||||
test('guests cannot consult or submit declarations', function () {
|
||||
auth()->logout();
|
||||
$this->get('/conflitos')->assertRedirect('/login');
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id))->assertRedirect('/login');
|
||||
});
|
||||
@@ -3,5 +3,5 @@
|
||||
test('returns a successful response', function () {
|
||||
$response = $this->get(route('home'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertRedirect('/dashboard');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?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]],
|
||||
];
|
||||
}
|
||||
|
||||
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'])->assertRedirect('/dashboard');
|
||||
expect($user->refresh()->purchasing_role)->toBe(PurchasingRole::Admin);
|
||||
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));
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Department;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierDocument;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
function maintenanceSupplierData(): array
|
||||
{
|
||||
return ['name' => 'Fornecedor existente', 'nuit' => '123456789', 'contact' => 'Contacto comercial', 'email' => 'fornecedor@example.test', 'phone' => '123456789', 'address' => 'Maputo', 'category' => 'Material', 'beneficiaries' => 'Beneficiário de teste', 'document_references' => 'Documentos verificados no processo de teste.', 'diligence_notes' => 'Documentação conferida para testes de manutenção.', 'blocked' => false, 'next_review' => now()->addYear()->format('Y-m-d')];
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('supplier_documents');
|
||||
$this->tenant = Tenant::query()->create(['name' => 'Manutenção A']);
|
||||
$department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Operações']);
|
||||
$this->buyer = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
$this->finance = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$this->actingAs($this->buyer);
|
||||
$this->supplier = Supplier::query()->create([...maintenanceSupplierData(), 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'status' => 'active', 'reviewed_by' => $this->finance->id, 'review_comment' => 'Validação anterior preservada em auditoria.']);
|
||||
$this->url = '/fornecedores/'.$this->supplier->id;
|
||||
});
|
||||
|
||||
test('procurement can edit and renew a supplier but cannot forge validation or ownership', function () {
|
||||
$this->get($this->url.'/editar')->assertInertia(fn (Assert $page) => $page->component('suppliers/edit')->where('supplier.version', 1));
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'name' => 'Nome actualizado', 'tenant_id' => 999, 'created_by' => 999, 'status' => 'active', 'reviewed_by' => 999])->assertSessionHasNoErrors()->assertRedirect($this->url);
|
||||
$supplier = $this->supplier->refresh();
|
||||
expect($supplier->name)->toBe('Nome actualizado')->and($supplier->status)->toBe('pending')->and($supplier->version)->toBe(2);
|
||||
expect($supplier->tenant_id)->toBe($this->tenant->id)->and($supplier->created_by)->toBe($this->buyer->id)->and($supplier->reviewed_by)->toBeNull()->and($supplier->last_changed_by)->toBe($this->buyer->id);
|
||||
expect($supplier->eligible())->toBeFalse();
|
||||
$audit = AuditLog::query()->where('action', 'supplier.updated')->firstOrFail();
|
||||
expect($audit->before['name'])->toBe('Fornecedor existente')->and($audit->after['name'])->toBe('Nome actualizado');
|
||||
});
|
||||
|
||||
test('unchanged edits preserve validation and do not create audit noise', function () {
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 1])->assertSessionHasNoErrors();
|
||||
expect($this->supplier->refresh()->status)->toBe('active')->and($this->supplier->version)->toBe(1);
|
||||
expect(AuditLog::query()->where('action', 'supplier.updated')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('stale edits cannot overwrite newer supplier data', function () {
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'name' => 'Actualização concorrente'])->assertSessionHasNoErrors();
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'name' => 'Ficha antiga'])->assertSessionHasErrors('version');
|
||||
expect($this->supplier->refresh()->name)->toBe('Actualização concorrente');
|
||||
});
|
||||
|
||||
test('editing validates nuit uniqueness within company and renewal date', function () {
|
||||
Supplier::query()->create([...maintenanceSupplierData(), 'nuit' => '987654321', 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id]);
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'nuit' => '987654321'])->assertSessionHasErrors('nuit');
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'next_review' => now()->subDay()->format('Y-m-d')])->assertSessionHasErrors('next_review');
|
||||
});
|
||||
|
||||
test('non procurement roles cannot change suppliers or upload documents', function (PurchasingRole $role) {
|
||||
$user = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => $role]);
|
||||
$this->actingAs($user)->get($this->url.'/editar')->assertForbidden();
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 1])->assertForbidden();
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 10, 'application/pdf')])->assertForbidden();
|
||||
expect(SupplierDocument::query()->count())->toBe(0);
|
||||
})->with([PurchasingRole::Finance, PurchasingRole::Admin, PurchasingRole::Requester, PurchasingRole::DepartmentHead, PurchasingRole::Warehouse]);
|
||||
|
||||
test('private upload saves metadata invalidates validation and audits without leaking storage paths', function () {
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 10, 'application/pdf')])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$document = SupplierDocument::query()->firstOrFail();
|
||||
Storage::disk('supplier_documents')->assertExists($document->path);
|
||||
expect($document->path)->toStartWith($this->tenant->id.'/'.$this->supplier->id.'/');
|
||||
expect($document->original_name)->toBe('alvara.pdf')->and(strlen($document->sha256))->toBe(64);
|
||||
expect($document->toArray())->not->toHaveKey('path');
|
||||
expect($this->supplier->refresh()->status)->toBe('pending')->and($this->supplier->version)->toBe(2);
|
||||
expect(AuditLog::query()->where('action', 'supplier.document_uploaded')->firstOrFail()->after['document'])->not->toHaveKey('path');
|
||||
$this->get($this->url)->assertInertia(fn (Assert $page) => $page->component('suppliers/show')->has('documents', 1)->missing('documents.0.path')->where('canEdit', true)->where('canDocuments', true));
|
||||
$this->get('/storage/'.$document->path)->assertForbidden();
|
||||
});
|
||||
|
||||
test('authorised downloads are attachments and recorded in audit', function (PurchasingRole $role) {
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'tax', 'file' => UploadedFile::fake()->create('certidao.pdf', 10, 'application/pdf')]);
|
||||
$document = SupplierDocument::query()->firstOrFail();
|
||||
$user = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => $role]);
|
||||
$this->actingAs($user)->get($this->url.'/documentos/'.$document->id)->assertOk()->assertDownload('certidao.pdf')->assertHeader('X-Content-Type-Options', 'nosniff')->assertHeader('Content-Type', 'application/octet-stream');
|
||||
expect(AuditLog::query()->where('action', 'supplier.document_downloaded')->count())->toBe(1);
|
||||
})->with([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]);
|
||||
|
||||
test('heads see the supplier but cannot inspect or download confidential documents', function () {
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'conflict', 'file' => UploadedFile::fake()->create('conflitos.pdf', 10, 'application/pdf')]);
|
||||
$document = SupplierDocument::query()->firstOrFail();
|
||||
$head = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$this->actingAs($head)->get($this->url)->assertInertia(fn (Assert $page) => $page->has('documents', 0)->where('canDocuments', false));
|
||||
$this->get($this->url.'/documentos/'.$document->id)->assertForbidden();
|
||||
});
|
||||
|
||||
test('uploads validate contents extension size and document classification', function () {
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('script.php', 1, 'text/x-php')])->assertSessionHasErrors('file');
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('script.php', 1, 'application/pdf')])->assertSessionHasErrors('file');
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('grande.pdf', 2049, 'application/pdf')])->assertSessionHasErrors('file');
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'invalid', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')])->assertSessionHasErrors('document_type');
|
||||
expect(SupplierDocument::query()->count())->toBe(0)->and(Storage::disk('supplier_documents')->allFiles())->toBe([]);
|
||||
expect($this->supplier->refresh()->status)->toBe('active');
|
||||
});
|
||||
|
||||
test('same company document cannot be downloaded under another supplier', function () {
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')]);
|
||||
$document = SupplierDocument::query()->firstOrFail();
|
||||
$other = Supplier::query()->create([...maintenanceSupplierData(), 'nuit' => '987654321', 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id]);
|
||||
$this->get('/fornecedores/'.$other->id.'/documentos/'.$document->id)->assertNotFound();
|
||||
expect(AuditLog::query()->where('action', 'supplier.document_downloaded')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('all maintenance and attachment routes reject other companies', function () {
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')]);
|
||||
$document = SupplierDocument::query()->firstOrFail();
|
||||
$other = Tenant::query()->create(['name' => 'Empresa B']);
|
||||
$user = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
$this->actingAs($user)->get($this->url)->assertNotFound();
|
||||
$this->get($this->url.'/editar')->assertNotFound();
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 2])->assertNotFound();
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')])->assertNotFound();
|
||||
$this->get($this->url.'/documentos/'.$document->id)->assertNotFound();
|
||||
});
|
||||
|
||||
test('review of a stale version is rejected after a new document upload', function () {
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')]);
|
||||
$this->actingAs($this->finance)->post($this->url.'/validar', ['status' => 'active', 'comment' => 'Revisão de ficha antiga não permitida.', 'version' => 1])->assertSessionHasErrors('version');
|
||||
$this->post($this->url.'/validar', ['status' => 'active', 'comment' => 'Dados e documento actual conferidos.', 'version' => 2])->assertSessionHasNoErrors();
|
||||
expect($this->supplier->refresh()->status)->toBe('active')->and($this->supplier->version)->toBe(3);
|
||||
});
|
||||
|
||||
test('last editor cannot validate the supplier after changing to finance role', function () {
|
||||
$editor = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
$this->actingAs($editor)->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'contact' => 'Novo contacto'])->assertSessionHasNoErrors();
|
||||
$editor->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
|
||||
$this->actingAs($editor)->post($this->url.'/validar', ['status' => 'active', 'comment' => 'Tentativa de validar alteração própria.', 'version' => 2])->assertForbidden();
|
||||
});
|
||||
|
||||
test('file is cleaned up and database rolled back if audit cannot be written', function () {
|
||||
$this->mock(RequisitionWorkflow::class, function ($mock) {
|
||||
$mock->shouldReceive('audit')->once()->andThrow(new RuntimeException('Falha de auditoria simulada.'));
|
||||
});
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')])->assertStatus(500);
|
||||
expect(SupplierDocument::query()->count())->toBe(0)->and(Storage::disk('supplier_documents')->allFiles())->toBe([]);
|
||||
expect($this->supplier->refresh()->status)->toBe('active')->and($this->supplier->version)->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Department;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
function supplierPayload(string $nuit = '123456789'): array
|
||||
{
|
||||
return ['name' => 'Fornecedor de teste', 'nuit' => $nuit, 'contact' => 'Contacto', 'email' => 'test@example.com', 'phone' => '123456789', 'address' => 'Maputo', 'category' => 'Material', 'beneficiaries' => 'Beneficiário teste', 'document_references' => 'Alvará e declaração verificados no processo 123.', 'diligence_notes' => 'Documentação conferida e sem impedimentos.', 'blocked' => false, 'next_review' => now()->addYear()->format('Y-m-d')];
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::query()->create(['name' => 'Cotação A']);
|
||||
$this->department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Produção']);
|
||||
$this->center = CostCenter::query()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'name' => 'Produção', 'code' => 'P']);
|
||||
$this->buyer = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
$this->finance = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$this->owner = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Requester]);
|
||||
$this->actingAs($this->buyer);
|
||||
$this->supplier = Supplier::query()->create([...supplierPayload(), 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'status' => 'active']);
|
||||
$this->r = Requisition::query()->create(['tenant_id' => $this->tenant->id, 'requester_id' => $this->owner->id, 'department_id' => $this->department->id, 'cost_center_id' => $this->center->id, 'number' => 'REQ-TEST', 'priority' => 'normal', 'needed_by' => now()->addWeek(), 'justification' => 'Pedido aprovado para testes.', 'status' => 'approved', 'subtotal_minor' => 15, 'tax_minor' => 2, 'total_minor' => 17]);
|
||||
$this->item = $this->r->items()->create(['tenant_id' => $this->tenant->id, 'description' => 'Material', 'category' => 'Material', 'purchase_type' => 'consumable', 'quantity' => '3', 'unit' => 'un', 'unit_price_minor' => 5, 'tax_rate' => '16', 'subtotal_minor' => 15, 'tax_minor' => 2, 'total_minor' => 17]);
|
||||
$this->quote = ['supplier_id' => $this->supplier->id, 'reference' => 'PROP-01', 'delivery_days' => 10, 'valid_until' => now()->addMonth()->format('Y-m-d'), 'payment_terms' => '30 dias', 'no_conflict' => true, 'items' => [['requisition_item_id' => $this->item->id, 'unit_price' => '0.05']]];
|
||||
});
|
||||
|
||||
test('supplier creation enforces role tenant and unique nuit', function () {
|
||||
$this->actingAs($this->owner)->post('/fornecedores', supplierPayload('987654321'))->assertForbidden();
|
||||
$this->actingAs($this->buyer)->post('/fornecedores', [...supplierPayload('987654321'), 'status' => 'active', 'tenant_id' => 999])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$s = Supplier::query()->where('nuit', '987654321')->firstOrFail();
|
||||
expect($s->status)->toBe('pending')->and($s->tenant_id)->toBe($this->tenant->id);
|
||||
$this->post('/fornecedores', supplierPayload('987654321'))->assertSessionHasErrors('nuit');
|
||||
$this->post('/fornecedores', supplierPayload('invalid'))->assertSessionHasErrors('nuit');
|
||||
});
|
||||
|
||||
test('supplier review is independent and prevents blocked activation', function () {
|
||||
$body = ['status' => 'active', 'comment' => 'Documentação verificada por Finanças.', 'version' => $this->supplier->version];
|
||||
$this->post("/fornecedores/{$this->supplier->id}/validar", $body)->assertForbidden();
|
||||
$this->buyer->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
|
||||
$this->actingAs($this->buyer)->post("/fornecedores/{$this->supplier->id}/validar", $body)->assertForbidden();
|
||||
$this->actingAs($this->finance)->post("/fornecedores/{$this->supplier->id}/validar", $body)->assertSessionHasNoErrors()->assertRedirect();
|
||||
$this->supplier->update(['blocked' => true]);
|
||||
$this->post("/fornecedores/{$this->supplier->id}/validar", [...$body, 'version' => $this->supplier->fresh()->version])->assertSessionHasErrors('status');
|
||||
});
|
||||
|
||||
test('quotes calculate decimal totals inherit tax and reject duplicates', function () {
|
||||
$this->post("/requisicoes/{$this->r->id}/cotacoes", [...$this->quote, 'total_minor' => 999, 'tenant_id' => 999])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$q = Quotation::query()->firstOrFail();
|
||||
expect($q->subtotal_minor)->toBe(15)->and($q->tax_minor)->toBe(2)->and($q->total_minor)->toBe(17)->and($q->tenant_id)->toBe($this->tenant->id);
|
||||
expect((float) $q->items()->first()->tax_rate)->toBe(16.0);
|
||||
$this->post("/requisicoes/{$this->r->id}/cotacoes", $this->quote)->assertSessionHasErrors('supplier_id');
|
||||
expect(Quotation::query()->count())->toBe(1);
|
||||
expect(AuditLog::query()->where('action', 'quotation.created')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('quotes require approval active suppliers valid dates and exact request items', function () {
|
||||
$url = "/requisicoes/{$this->r->id}/cotacoes";
|
||||
$this->r->update(['status' => 'draft']);
|
||||
$this->post($url, $this->quote)->assertForbidden();
|
||||
$this->r->update(['status' => 'approved']);
|
||||
$this->supplier->update(['status' => 'pending']);
|
||||
$this->post($url, $this->quote)->assertSessionHasErrors('supplier_id');
|
||||
$this->supplier->update(['status' => 'active']);
|
||||
$this->post($url, [...$this->quote, 'valid_until' => now()->subDay()->format('Y-m-d')])->assertSessionHasErrors('valid_until');
|
||||
$this->post($url, [...$this->quote, 'items' => [['requisition_item_id' => 999, 'unit_price' => '1']]])->assertSessionHasErrors('items');
|
||||
$this->post($url, [...$this->quote, 'no_conflict' => false])->assertSessionHasErrors('no_conflict');
|
||||
expect(Quotation::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('two independent evaluations and three eligible suppliers complete comparison', function () {
|
||||
foreach (['123456789', '123456788', '123456787'] as $index => $nuit) {
|
||||
$s = $index === 0 ? $this->supplier : Supplier::query()->create([...supplierPayload($nuit), 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'status' => 'active']);
|
||||
$this->actingAs($this->buyer)->post("/requisicoes/{$this->r->id}/cotacoes", [...$this->quote, 'supplier_id' => $s->id])->assertSessionHasNoErrors();
|
||||
$q = Quotation::query()->latest('id')->firstOrFail();
|
||||
$ev = ['quality' => 8, 'history' => 5, 'payment' => 8, 'comment' => 'Avaliação técnica independente.', 'no_conflict' => true];
|
||||
$this->post("/cotacoes/{$q->id}/avaliar", $ev)->assertSessionHasNoErrors();
|
||||
$this->post("/cotacoes/{$q->id}/avaliar", $ev)->assertSessionHasErrors('evaluation');
|
||||
$this->actingAs($this->finance)->post("/cotacoes/{$q->id}/avaliar", $ev)->assertSessionHasNoErrors();
|
||||
}
|
||||
$this->get("/requisicoes/{$this->r->id}/cotacoes")->assertInertia(fn (Assert $page) => $page->component('quotations/show')->where('ready', true)->has('quotations', 3)->where('quotations.0.score', 8.65));
|
||||
$this->supplier->update(['status' => 'suspended']);
|
||||
$this->get("/requisicoes/{$this->r->id}/cotacoes")->assertInertia(fn (Assert $page) => $page->where('ready', false)->where('quotations.0.eligible', false));
|
||||
});
|
||||
|
||||
test('requester cannot evaluate own purchase after role change', function () {
|
||||
$this->post("/requisicoes/{$this->r->id}/cotacoes", $this->quote);
|
||||
$q = Quotation::query()->firstOrFail();
|
||||
$this->owner->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
|
||||
$this->actingAs($this->owner)->post("/cotacoes/{$q->id}/avaliar", ['quality' => 8, 'history' => 5, 'payment' => 8, 'comment' => 'Avaliação de pedido próprio.', 'no_conflict' => true])->assertForbidden();
|
||||
});
|
||||
|
||||
test('supplier quote and evaluation routes isolate companies', function () {
|
||||
$this->post("/requisicoes/{$this->r->id}/cotacoes", $this->quote);
|
||||
$q = Quotation::query()->firstOrFail();
|
||||
$tenant = Tenant::query()->create(['name' => 'Empresa B']);
|
||||
$other = User::factory()->create(['tenant_id' => $tenant->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$this->actingAs($other)->get('/fornecedores')->assertInertia(fn (Assert $p) => $p->has('suppliers.data', 0));
|
||||
$this->post("/fornecedores/{$this->supplier->id}/validar", ['status' => 'active', 'comment' => 'Tentativa de outro inquilino.'])->assertNotFound();
|
||||
$this->get("/requisicoes/{$this->r->id}/cotacoes")->assertNotFound();
|
||||
$this->post("/cotacoes/{$q->id}/avaliar", ['quality' => 8, 'history' => 5, 'payment' => 8, 'comment' => 'Tentativa de outro inquilino.', 'no_conflict' => true])->assertNotFound();
|
||||
});
|
||||
Reference in New Issue
Block a user