151 lines
11 KiB
PHP
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();
|
|
});
|