Files
compras/tests/Feature/PurchasePlanningTest.php
T

151 lines
11 KiB
PHP

<?php
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\BudgetEntry;
use App\Models\Department;
use App\Models\PurchasePlanItem;
use App\Models\PurchasePlanMilestone;
use App\Models\Tenant;
use App\Models\User;
use App\Services\PurchasePlanningWorkflow;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/ReceiptFixture.php';
beforeEach(function () {
$this->f = receiptFixture();
$this->data = ['title' => 'Plano fictício de materiais', 'nature' => 'goods', 'department_id' => $this->f['department']->id, 'responsible_id' => $this->f['receiver']->id, 'requisition_id' => null];
});
function createPlanningItem(object $test, array $overrides = []): PurchasePlanItem
{
$test->actingAs($test->f['issuer'])->post('/planeamento', [...$test->data, ...$overrides])->assertSessionHasNoErrors();
return PurchasePlanItem::query()->latest('id')->firstOrFail();
}
function createPlanningMilestone(object $test, PurchasePlanItem $item): PurchasePlanMilestone
{
$test->actingAs($test->f['issuer'])->post('/planeamento/'.$item->id.'/marcos', ['label' => 'Concluir avaliação', 'planned_on' => today()->subDays(2)->format('Y-m-d')])->assertSessionHasNoErrors();
return PurchasePlanMilestone::query()->latest('id')->firstOrFail();
}
test('planning records responsibility without changing financial or requisition state', function () {
$item = createPlanningItem($this, ['requisition_id' => (string) $this->f['r']->id, 'department_id' => (string) $this->f['department']->id]);
expect($item->requisition_id)->toBe($this->f['r']->id)->and(BudgetEntry::query()->count())->toBe(0)->and($this->f['r']->refresh()->status)->toBe('approved');
$this->get('/planeamento')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')
->assertInertia(fn (Assert $page) => $page->component('planning/index')->where('items.data.0.responsible', $this->f['receiver']->name)->where('items.data.0.requisition.number', $this->f['r']->number)->has('milestones', 0));
});
test('planning milestones are chosen per purchase and duplicate labels are rejected', function () {
$item = createPlanningItem($this);
$milestone = createPlanningMilestone($this, $item);
expect($milestone->actual_on)->toBeNull();
$this->post('/planeamento/'.$item->id.'/marcos', ['label' => 'Concluir avaliação', 'planned_on' => today()->format('Y-m-d')])->assertSessionHasErrors('label');
expect(PurchasePlanMilestone::query()->count())->toBe(1);
});
test('assigned responsible can declare an actual date but cannot reschedule the plan', function () {
$milestone = createPlanningMilestone($this, createPlanningItem($this));
$this->actingAs($this->f['receiver'])->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->format('Y-m-d'), 'reason' => 'Registo real declarado no teste'])->assertSessionHasNoErrors();
expect($milestone->refresh()->actual_on)->toBe(today()->format('Y-m-d'))->and($milestone->version)->toBe(2);
$this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 2, 'planned_on' => today()->addWeek()->format('Y-m-d'), 'reason' => 'Tentativa de alterar a previsão'])->assertForbidden();
});
test('planning rejects stale dates and preserves their prior values and author', function () {
$milestone = createPlanningMilestone($this, createPlanningItem($this));
$this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'planned_on' => today()->format('Y-m-d'), 'reason' => 'Reprogramação fundamentada de teste'])->assertSessionHasNoErrors();
$this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'planned_on' => today()->addWeek()->format('Y-m-d'), 'reason' => 'Segunda gravação desactualizada'])->assertSessionHasErrors('version');
$event = AuditLog::query()->where('action', 'planning.milestone_updated')->sole();
expect($event->before['planned_on'])->toBe(today()->subDays(2)->format('Y-m-d'))->and($event->after['milestone']['planned_on'])->toBe(today()->format('Y-m-d'))->and($event->user_id)->toBe($this->f['issuer']->id);
});
test('future actual dates and missing reasons are refused', function () {
$milestone = createPlanningMilestone($this, createPlanningItem($this));
$this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->addDay()->format('Y-m-d'), 'reason' => 'Data futura não realizada'])->assertSessionHasErrors('actual_on');
$this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->format('Y-m-d')])->assertSessionHasErrors('reason');
expect($milestone->refresh()->version)->toBe(1);
});
test('finance is a planning reader and unrelated staff cannot alter or inspect assigned plans', function () {
$item = createPlanningItem($this);
$milestone = createPlanningMilestone($this, $item);
$finance = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$this->actingAs($finance)->get('/planeamento')->assertInertia(fn (Assert $page) => $page->where('canManage', false)->has('items.data', 1)->has('users', 0)->has('history', 0));
$this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->format('Y-m-d'), 'reason' => 'Operador sem responsabilidade atribuída'])->assertForbidden();
$this->actingAs($this->f['otherReceiver'])->get('/planeamento')->assertInertia(fn (Assert $page) => $page->has('items.data', 0)->has('milestones', 0)->has('history', 0));
$this->post('/planeamento', $this->data)->assertForbidden();
});
test('planning protects company and department boundaries', function () {
$item = createPlanningItem($this);
$other = Tenant::query()->create(['name' => 'Outra empresa fictícia']);
$foreign = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]);
$this->actingAs($foreign)->get('/planeamento')->assertInertia(fn (Assert $page) => $page->has('items.data', 0));
$this->post('/planeamento/'.$item->id.'/marcos', ['label' => 'Marco indevido', 'planned_on' => today()->format('Y-m-d')])->assertNotFound();
$this->actingAs($this->f['issuer'])->post('/planeamento', [...$this->data, 'responsible_id' => $foreign->id])->assertSessionHasErrors('responsible_id');
$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('/planeamento')->assertInertia(fn (Assert $page) => $page->has('items.data', 0));
});
test('a later requisition link is unique and cannot replace its predecessor', function () {
$item = createPlanningItem($this);
$this->post('/planeamento/'.$item->id.'/requisicao', ['version' => 1, 'requisition_id' => $this->f['r']->id, 'reason' => 'Ligação posterior ao planeamento'])->assertSessionHasNoErrors();
expect($item->refresh()->requisition_id)->toBe($this->f['r']->id)->and($item->version)->toBe(2);
$this->post('/planeamento', [...$this->data, 'requisition_id' => $this->f['r']->id])->assertSessionHasErrors('requisition_id');
$this->post('/planeamento/'.$item->id.'/requisicao', ['version' => 2, 'requisition_id' => $this->f['r']->id, 'reason' => 'Não substituir ligação já existente'])->assertSessionHasErrors('requisition_id');
});
test('responsibility changes preserve history and revoke previous write access', function () {
$item = createPlanningItem($this);
$milestone = createPlanningMilestone($this, $item);
$this->post('/planeamento/'.$item->id.'/responsavel', ['version' => 1, 'responsible_id' => $this->f['otherReceiver']->id, 'reason' => 'Mudança de responsável no teste'])->assertSessionHasNoErrors();
$this->actingAs($this->f['receiver'])->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->format('Y-m-d'), 'reason' => 'Operador anterior já substituído'])->assertNotFound();
expect(AuditLog::query()->where('action', 'planning.assigned')->count())->toBe(1);
});
test('planning refreshes revoked operator roles inside the transaction', function () {
$item = createPlanningItem($this);
DB::table('users')->where('id', $this->f['issuer']->id)->update(['purchasing_role' => 'requester']);
$this->post('/planeamento/'.$item->id.'/marcos', ['label' => 'Marco após revogação', 'planned_on' => today()->format('Y-m-d')])->assertForbidden();
expect(PurchasePlanMilestone::query()->count())->toBe(0);
});
test('audit failure rolls back milestone dates and optimistic version together', function () {
$milestone = createPlanningMilestone($this, createPlanningItem($this));
AuditLog::creating(function (AuditLog $event) {
if ($event->action === 'planning.milestone_updated') {
throw new RuntimeException('Auditoria fictícia indisponível');
}
});
try {
expect(fn () => app(PurchasePlanningWorkflow::class)->update($this->f['issuer'], $milestone, ['version' => 1, 'actual_on' => today()->format('Y-m-d'), 'reason' => 'Teste de atomicidade']))->toThrow(RuntimeException::class);
} finally {
AuditLog::flushEventListeners();
}
expect($milestone->refresh()->actual_on)->toBeNull()->and($milestone->version)->toBe(1);
});
test('planning links only requisitions of its department and appears in the visible process', function () {
$department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Departamento diferente']);
$item = createPlanningItem($this, ['department_id' => $department->id]);
$this->post('/planeamento/'.$item->id.'/requisicao', ['version' => 1, 'requisition_id' => $this->f['r']->id, 'reason' => 'Ligação a departamento diferente'])->assertSessionHasErrors('requisition_id');
$linked = createPlanningItem($this, ['requisition_id' => $this->f['r']->id]);
$this->get('/requisicoes/'.$this->f['r']->id.'/processo')->assertInertia(fn (Assert $page) => $page->where('documents', fn ($documents) => collect($documents)->contains('href', '/planeamento?item='.$linked->id)));
$this->get('/planeamento?item='.$linked->id)->assertInertia(fn (Assert $page) => $page->has('items.data', 1)->where('items.data.0.id', $linked->id));
});
test('an explicitly assigned department head sees only the plan beyond department access', function () {
$department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Departamento diferente']);
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
$item = createPlanningItem($this, ['responsible_id' => $head->id, 'requisition_id' => $this->f['r']->id]);
$milestone = createPlanningMilestone($this, $item);
$this->actingAs($head)->get('/planeamento')->assertInertia(fn (Assert $page) => $page->has('items.data', 1)->where('items.data.0.requisition', null)->where('items.data.0.canRecord', true));
$this->patch('/planeamento/marcos/'.$milestone->id, ['version' => 1, 'actual_on' => today()->format('Y-m-d'), 'reason' => 'Responsável explicitamente designado'])->assertSessionHasNoErrors();
$this->get('/requisicoes/'.$this->f['r']->id.'/processo')->assertNotFound();
});