Files
compras/tests/Feature/InternalRequisitionCandidateTest.php
T
2026-11-02 08:08:05 +02:00

247 lines
19 KiB
PHP

<?php
use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\Requisition;
use App\Models\User;
use App\Services\OrganizationAccess;
use App\Services\RequisitionWorkflow;
use App\Services\WorkflowNotices;
use App\Services\WorkflowTasks;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
require_once __DIR__.'/../Support/ReceiptFixture.php';
beforeEach(function () {
$this->f = receiptFixture();
$this->actor = $this->f['issuer'];
$this->f['tenant']->update(['first_limit_minor' => 1000000, 'second_limit_minor' => 2000000, 'rules_provisional' => false]);
$this->head = User::factory()->create(['tenant_id' => $this->actor->tenant_id, 'department_id' => $this->actor->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
Budget::query()->create(['tenant_id' => $this->actor->tenant_id, 'cost_center_id' => $this->f['r']->cost_center_id, 'year' => now()->year, 'amount_minor' => 1000000, 'includes_tax' => true, 'reason' => 'Dotação sintética aprovada', 'created_by' => $this->actor->id, 'approved_by' => $this->head->id]);
$this->data = ['operation_key' => (string) Str::uuid(), 'cost_center_id' => $this->f['r']->cost_center_id, 'priority' => 'normal', 'needed_by' => now()->addWeek()->toDateString(), 'justification' => 'Necessidade operacional sintética.',
'items' => [['description' => 'Material', 'category' => 'Consumíveis', 'purchase_type' => 'consumable', 'quantity' => '2.000', 'unit' => 'UN', 'unit_price' => '50.00', 'tax_rate' => '16.00']]];
$this->actingAs($this->actor);
});
test('autonomous adapter executes shared lifecycle with one request one reserve and independent versioned approval', function () {
$count = Requisition::query()->count();
$this->postJson('/requisicoes', $this->data)->assertRedirect();
$this->postJson('/requisicoes', $this->data)->assertRedirect();
expect(Requisition::query()->count())->toBe($count + 1);
$r = Requisition::query()->latest('id')->firstOrFail();
$submit = ['operation_key' => (string) Str::uuid(), 'process_version' => 1];
$this->postJson('/requisicoes/'.$r->id.'/submeter', $submit)->assertRedirect();
$this->postJson('/requisicoes/'.$r->id.'/submeter', $submit)->assertRedirect();
expect(DB::table('budget_entries')->where('requisition_id', $r->id)->where('kind', 'reserve')->count())->toBe(1)
->and($r->fresh()->process_version)->toBe(2)->and($r->steps()->count())->toBe(1);
$decision = ['operation_key' => (string) Str::uuid(), 'process_version' => 2, 'decision' => 'approved'];
$this->postJson('/requisicoes/'.$r->id.'/decidir', $decision)->assertForbidden();
$this->actingAs($this->head)->postJson('/requisicoes/'.$r->id.'/decidir', $decision)->assertRedirect();
$this->postJson('/requisicoes/'.$r->id.'/decidir', $decision)->assertRedirect();
expect($r->fresh()->status)->toBe('approved')->and($r->fresh()->process_version)->toBe(3)
->and(DB::table('requisition_commands')->where('tenant_id', $this->actor->tenant_id)->count())->toBe(3);
$this->get('/requisicoes/'.$r->id)->assertOk();
});
test('source adapter refuses changed replay stale version revoked role and another tenant', function () {
$this->postJson('/requisicoes', $this->data)->assertRedirect();
$r = Requisition::query()->latest('id')->firstOrFail();
$this->postJson('/requisicoes', [...$this->data, 'justification' => 'Outro conteúdo para a mesma operação.'])->assertConflict();
$this->putJson('/requisicoes/'.$r->id, [...$this->data, 'operation_key' => (string) Str::uuid(), 'process_version' => 99])->assertConflict();
$this->actor->forceFill(['purchasing_role' => PurchasingRole::Configured])->save();
$this->actingAs($this->actor)->postJson('/requisicoes', $this->data)->assertForbidden();
$foreign = receiptFixture();
$this->actingAs($foreign['issuer'])->get('/requisicoes/'.$r->id)->assertNotFound();
expect(DB::table('budget_entries')->where('requisition_id', $r->id)->count())->toBe(0);
});
test('failure after reserve rolls back source steps history and command receipt and exact retry recovers', function () {
$this->postJson('/requisicoes', $this->data)->assertRedirect();
$r = Requisition::query()->latest('id')->firstOrFail();
$command = ['operation_key' => (string) Str::uuid(), 'process_version' => 1];
$this->partialMock(RequisitionWorkflow::class, function ($mock) {
$mock->shouldReceive('audit')->andReturnUsing(function (...$args) {
if ($args[1] === 'requisition.submitted') {
throw new RuntimeException('Injected audit failure');
}(new RequisitionWorkflow)->audit(...$args);
});
});
$this->withoutExceptionHandling();
expect(fn () => $this->postJson('/requisicoes/'.$r->id.'/submeter', $command))->toThrow(RuntimeException::class);
expect(DB::table('budget_entries')->where('requisition_id', $r->id)->count())->toBe(0)->and($r->steps()->count())->toBe(0)->and($r->fresh()->process_version)->toBe(1);
$this->app->forgetInstance(RequisitionWorkflow::class);
$this->postJson('/requisicoes/'.$r->id.'/submeter', $command)->assertRedirect();
expect(DB::table('budget_entries')->where('requisition_id', $r->id)->count())->toBe(1)->and($r->fresh()->status)->toBe('pending');
});
test('source additive migration preserves existing data and recovers transactional schema failure without code rollback', function () {
$migration = require base_path('database/migrations/2026_11_01_000046_add_requisition_command_versions.php');
$migration->down();
$baseline = DB::table('requisitions')->where('id', $this->f['r']->id)->first();
$fail = true;
DB::listen(function ($q) use (&$fail) {
if ($fail && str_contains($q->sql, 'create table "requisition_commands"')) {
$fail = false;
throw new RuntimeException('Injected source schema failure');
}
});
expect(fn () => DB::transaction(fn () => $migration->up()))->toThrow(RuntimeException::class);
expect(Schema::hasColumn('requisitions', 'process_version'))->toBeFalse()
->and((array) DB::table('requisitions')->where('id', $this->f['r']->id)->first())->toBe((array) $baseline);
DB::transaction(fn () => $migration->up());
$after = (array) DB::table('requisitions')->where('id', $this->f['r']->id)->first();
unset($after['process_version']);
expect($after)->toBe((array) $baseline)->and(DB::table('requisition_commands')->count())->toBe(0);
});
test('approval routing connects the existing stage to eligible people without changing decisions or granting powers', function () {
$this->postJson('/requisicoes', $this->data)->assertRedirect();
$r = Requisition::query()->latest('id')->firstOrFail();
$this->postJson('/requisicoes/'.$r->id.'/submeter', ['operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertRedirect();
$admin = User::factory()->create(['tenant_id' => $r->tenant_id, 'purchasing_role' => PurchasingRole::Admin]);
$wrongDepartment = User::factory()->create(['tenant_id' => $r->tenant_id, 'department_id' => null, 'purchasing_role' => PurchasingRole::DepartmentHead]);
$foreign = receiptFixture();
$before = $r->fresh()->toArray();
$audits = DB::table('audit_logs')->count();
$this->actingAs($this->actor)->get('/requisicoes/'.$r->id)->assertOk()->assertInertia(fn (AssertableInertia $p) => $p
->where('approvalRouting.status', 'available')->where('approvalRouting.task_key', 'requisition.approve.department_head')
->has('approvalRouting.people', 1)->where('approvalRouting.people.0.id', $this->head->id)
->missing('approvalRouting.people.0.email'));
expect($r->fresh()->toArray())->toBe($before)->and(DB::table('audit_logs')->count())->toBe($audits);
$this->head->forceFill(['purchasing_role' => PurchasingRole::Configured])->save();
$this->get('/requisicoes/'.$r->id)->assertInertia(fn (AssertableInertia $p) => $p
->where('approvalRouting.status', 'unavailable')->has('approvalRouting.people', 0));
$this->actingAs($foreign['issuer'])->get('/requisicoes/'.$r->id)->assertNotFound();
});
test('profile task catalogue covers the existing permissions without creating a second authority source', function () {
$catalogue = app(WorkflowTasks::class)->catalogue();
expect(array_keys($catalogue))->toBe(array_keys(OrganizationAccess::PERMISSIONS))
->and($catalogue['department_head'][0]['key'])->toBe('requisition.approve.department_head')
->and($catalogue['procurement'][0]['key'])->toBe('requisition.approve.procurement')
->and($catalogue['finance'][0]['key'])->toBe('requisition.approve.finance');
$this->actor->forceFill(['purchasing_role' => PurchasingRole::Admin])->save();
$this->actingAs($this->actor)->get('/administracao/parametrizacao')->assertOk()->assertInertia(fn (AssertableInertia $p) => $p
->where('workflowTasks.department_head.0.key', 'requisition.approve.department_head'));
});
test('internal notices are created once with submission and advance only after a valid decision', function () {
$this->f['tenant']->update(['first_limit_minor' => 1, 'second_limit_minor' => 2]);
$buyer = User::factory()->create(['tenant_id' => $this->actor->tenant_id, 'department_id' => $this->actor->department_id, 'purchasing_role' => PurchasingRole::Procurement]);
$this->postJson('/requisicoes', $this->data)->assertRedirect();
$r = Requisition::query()->latest('id')->firstOrFail();
$submit = ['operation_key' => (string) Str::uuid(), 'process_version' => 1];
$this->postJson('/requisicoes/'.$r->id.'/submeter', $submit)->assertRedirect();
$this->postJson('/requisicoes/'.$r->id.'/submeter', $submit)->assertRedirect();
expect(DB::table('workflow_notices')->count())->toBe(1);
$notice = DB::table('workflow_notices')->sole();
expect($notice->recipient_id)->toBe($this->head->id);
$this->postJson('/requisicoes/avisos/'.$notice->id.'/ler')->assertNotFound();
$this->actingAs($this->head)->get('/requisicoes/avisos')->assertOk()->assertInertia(fn (AssertableInertia $p) => $p->where('unread', 1)->where('notices.total', 1));
$before = $r->fresh()->toArray();
$this->post('/requisicoes/avisos/'.$notice->id.'/ler')->assertRedirect();
$this->post('/requisicoes/avisos/'.$notice->id.'/ler')->assertRedirect();
expect($r->fresh()->toArray())->toBe($before)->and(DB::table('audit_logs')->where('action', 'workflow.notice_read')->count())->toBe(1);
$decision = ['operation_key' => (string) Str::uuid(), 'process_version' => 2, 'decision' => 'approved'];
$this->postJson('/requisicoes/'.$r->id.'/decidir', $decision)->assertRedirect();
$this->postJson('/requisicoes/'.$r->id.'/decidir', $decision)->assertRedirect();
expect(DB::table('workflow_notices')->count())->toBe(2);
$this->get('/requisicoes/avisos')->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0));
$this->actingAs($buyer)->get('/requisicoes/avisos')->assertInertia(fn (AssertableInertia $p) => $p->where('unread', 1)->where('notices.total', 1));
$buyer->forceFill(['purchasing_role' => PurchasingRole::Configured])->save();
$this->actingAs($buyer)->get('/requisicoes/avisos')->assertForbidden();
expect(app(WorkflowNotices::class)->visible($buyer))->toBe([]);
});
test('failure to persist a notice rolls back the submission reservation audit and command receipt together', function () {
$this->postJson('/requisicoes', $this->data)->assertRedirect();
$r = Requisition::query()->latest('id')->firstOrFail();
$audits = DB::table('audit_logs')->count();
$this->mock(WorkflowNotices::class)->shouldReceive('capture')->once()->andThrow(new RuntimeException('Injected notice persistence failure'));
expect(fn () => app(RequisitionWorkflow::class)->submit($this->actor, $r, (string) Str::uuid(), 1))->toThrow(RuntimeException::class);
expect($r->fresh()->status)->toBe('draft')->and($r->steps()->count())->toBe(0)
->and(DB::table('workflow_notices')->count())->toBe(0)->and(DB::table('budget_entries')->where('requisition_id', $r->id)->count())->toBe(0)
->and(DB::table('audit_logs')->count())->toBe($audits);
});
test('requester receives one final outcome notice with the original decision and current state', function (string $decision) {
$this->postJson('/requisicoes', $this->data)->assertRedirect();
$r = Requisition::query()->latest('id')->firstOrFail();
$this->postJson('/requisicoes/'.$r->id.'/submeter', ['operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertRedirect();
$payload = ['operation_key' => (string) Str::uuid(), 'process_version' => 2, 'decision' => $decision, 'comment' => 'Fundamento da decisão final de ensaio.'];
$this->actingAs($this->head)->postJson('/requisicoes/'.$r->id.'/decidir', $payload)->assertRedirect();
$this->postJson('/requisicoes/'.$r->id.'/decidir', $payload)->assertRedirect();
expect(DB::table('workflow_notices')->where('requisition_id', $r->id)->count())->toBe(2);
$this->get('/requisicoes/avisos')->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0));
$this->actingAs($this->actor)->get('/requisicoes/avisos?view=outcomes')->assertOk()->assertInertia(fn (AssertableInertia $p) => $p
->where('notices.total', 1)->where('notices.data.0.kind', 'outcome')->where('notices.data.0.outcome', $decision)
->where('notices.data.0.current_status', $decision)->missing('notices.data.0.comment'));
$this->get('/requisicoes/avisos?view=tasks')->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0));
$notice = DB::table('workflow_notices')->where('recipient_id', $this->actor->id)->sole();
$before = $r->fresh()->toArray();
$this->post('/requisicoes/avisos/'.$notice->id.'/ler')->assertRedirect();
$this->post('/requisicoes/avisos/'.$notice->id.'/ler')->assertRedirect();
expect($r->fresh()->toArray())->toBe($before);
$this->get('/requisicoes/avisos?view=unread')->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0));
$this->actingAs($this->head)->post('/requisicoes/avisos/'.$notice->id.'/ler')->assertNotFound();
if ($decision === 'approved') {
// Simulate a later authoritative state; the test does not implement cancellation.
$r->update(['status' => 'cancelled']);
$this->actingAs($this->actor)->get('/requisicoes/avisos?view=outcomes')->assertInertia(fn (AssertableInertia $p) => $p
->where('notices.data.0.outcome', 'approved')->where('notices.data.0.current_status', 'cancelled'));
}
})->with(['approved', 'rejected']);
test('failure to persist requester outcome rolls back the final decision and preserves its pending task', function () {
$this->postJson('/requisicoes', $this->data)->assertRedirect();
$r = Requisition::query()->latest('id')->firstOrFail();
$this->postJson('/requisicoes/'.$r->id.'/submeter', ['operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertRedirect();
$audits = DB::table('audit_logs')->count();
$this->mock(WorkflowNotices::class)->shouldReceive('capture')->once();
app(WorkflowNotices::class)->shouldReceive('outcome')->once()->andThrow(new RuntimeException('Injected outcome persistence failure'));
$this->actingAs($this->head);
expect(fn () => app(RequisitionWorkflow::class)->decide($this->head, $r, 'rejected', 'Fundamento de teste.', (string) Str::uuid(), 2))->toThrow(RuntimeException::class);
expect($r->fresh()->status)->toBe('pending')->and($r->steps()->sole()->decision)->toBe('pending')
->and(DB::table('workflow_notices')->count())->toBe(1)->and(DB::table('audit_logs')->count())->toBe($audits)
->and(DB::table('budget_entries')->where('requisition_id', $r->id)->where('kind', 'release')->count())->toBe(0);
});
test('controlled routing refresh notifies new eligible people once without changing approval or read history', function () {
$this->postJson('/requisicoes', $this->data)->assertRedirect();
$r = Requisition::query()->latest('id')->firstOrFail();
$this->postJson('/requisicoes/'.$r->id.'/submeter', ['operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertRedirect();
$original = DB::table('workflow_notices')->sole();
$this->actingAs($this->head)->post('/requisicoes/avisos/'.$original->id.'/ler')->assertRedirect();
$this->head->forceFill(['purchasing_role' => PurchasingRole::Configured])->save();
$replacement = User::factory()->create(['tenant_id' => $r->tenant_id, 'department_id' => $r->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
$before = $r->fresh()->toArray();
$readAt = DB::table('workflow_notices')->where('id', $original->id)->value('read_at');
$payload = ['operation_key' => (string) Str::uuid(), 'process_version' => 2, 'step_id' => $r->steps()->sole()->id, 'reason' => 'Mudança autorizada da pessoa responsável.'];
$url = '/requisicoes/'.$r->id.'/encaminhamento';
$this->actingAs($this->actor)->postJson($url, $payload)->assertRedirect();
$this->postJson($url, $payload)->assertRedirect();
expect($r->fresh()->toArray())->toBe($before)->and(DB::table('workflow_notices')->count())->toBe(2)
->and(DB::table('workflow_notices')->where('id', $original->id)->value('read_at'))->toBe($readAt)
->and(DB::table('audit_logs')->where('action', 'workflow.routing_refreshed')->count())->toBe(1);
$this->postJson($url, [...$payload, 'reason' => 'Conteúdo diferente para a mesma operação.'])->assertUnprocessable();
$this->postJson($url, [...$payload, 'operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertUnprocessable();
$this->actingAs($replacement)->get('/requisicoes/avisos')->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 1)->where('unread', 1));
$this->postJson($url, [...$payload, 'operation_key' => (string) Str::uuid()])->assertForbidden();
$foreign = receiptFixture();
$this->actingAs($foreign['issuer'])->postJson($url, $payload)->assertNotFound();
});
test('routing refresh audit failure rolls back new recipient notices', function () {
$this->postJson('/requisicoes', $this->data)->assertRedirect();
$r = Requisition::query()->latest('id')->firstOrFail();
$this->postJson('/requisicoes/'.$r->id.'/submeter', ['operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertRedirect();
User::factory()->create(['tenant_id' => $r->tenant_id, 'department_id' => $r->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected routing audit failure'));
expect(fn () => app(WorkflowNotices::class)->refresh($this->actor, $r->id, ['operation_key' => (string) Str::uuid(), 'process_version' => 2,
'step_id' => $r->steps()->sole()->id, 'reason' => 'Conferência autorizada do encaminhamento.']))->toThrow(RuntimeException::class);
expect(DB::table('workflow_notices')->count())->toBe(1)->and($r->fresh()->status)->toBe('pending');
});