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('requisition audit export includes every metadata record without private payloads or read side effects', function () { $this->postJson('/requisicoes', $this->data)->assertRedirect(); $r = Requisition::query()->latest('id')->firstOrFail(); for ($i = 0; $i < 105; $i++) { AuditLog::query()->create(['tenant_id' => $r->tenant_id, 'requisition_id' => $r->id, 'user_id' => $this->actor->id, 'action' => $i === 0 ? "\t=FORMULA-TEST" : 'qa.metadata.'.$i, 'created_at' => now(), 'before' => ['bank' => 'PRIVATE-BEFORE-MARKER'], 'after' => ['account_identifier' => 'PRIVATE-AFTER-MARKER', 'execution' => ['mode' => 'scheduled_replenishment']]]); } $expectedIds = AuditLog::query()->where('requisition_id', $r->id)->orderBy('id')->pluck('id')->all(); $before = []; foreach (['requisitions', 'approval_steps', 'budget_entries', 'stock_movements', 'audit_logs'] as $table) { $before[$table] = DB::table($table)->orderBy('id')->get()->toArray(); } $response = $this->get('/requisicoes/'.$r->id.'/historico.csv')->assertOk()->assertHeader('Content-Type', 'text/csv; charset=UTF-8') ->assertHeader('Cache-Control', 'max-age=0, no-store, private'); $csv = $response->streamedContent(); expect($csv)->not->toContain('PRIVATE-BEFORE-MARKER')->not->toContain('PRIVATE-AFTER-MARKER')->toContain('scheduled_replenishment'); $stream = fopen('php://temp', 'w+'); fwrite($stream, $csv); rewind($stream); fgetcsv($stream, null, ';', '"', ''); fgetcsv($stream, null, ';', '"', ''); $ids = []; $actions = []; while (($row = fgetcsv($stream, null, ';', '"', '')) !== false) { $ids[] = (int) $row[0]; $actions[] = $row[2]; expect($row)->toHaveCount(5); } fclose($stream); expect($ids)->toBe($expectedIds)->and($actions[1])->toStartWith("'"); $this->get('/requisicoes/'.$r->id)->assertInertia(fn (AssertableInertia $p) => $p ->has('audit', 25)->where('auditPagination.total', count($expectedIds))->missing('audit.1.after')->where('audit.1.execution', 'scheduled_replenishment')); $this->get('/requisicoes/'.$r->id.'?audit_page=2')->assertInertia(fn (AssertableInertia $p) => $p ->has('audit', 25)->where('audit.0.id', $expectedIds[25])->where('auditPagination.current_page', 2)); $this->get('/requisicoes/'.$r->id.'?audit_page=5')->assertInertia(fn (AssertableInertia $p) => $p ->has('audit', 6)->where('audit.0.id', $expectedIds[100])->where('auditPagination.next_page_url', null)); $this->getJson('/requisicoes/'.$r->id.'?audit_page=0')->assertUnprocessable(); $this->getJson('/requisicoes/'.$r->id.'?audit_page=1000001')->assertUnprocessable(); foreach ($before as $table => $rows) { expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows); } }); test('requisition audit export preserves requester department company and current access boundaries', function () { $this->postJson('/requisicoes', $this->data)->assertRedirect(); $r = Requisition::query()->latest('id')->firstOrFail(); $url = '/requisicoes/'.$r->id.'/historico.csv'; $other = User::factory()->create(['tenant_id' => $r->tenant_id, 'department_id' => $r->department_id, 'purchasing_role' => PurchasingRole::Requester]); $this->actingAs($other)->get($url)->assertNotFound(); $department = Department::query()->create(['tenant_id' => $r->tenant_id, 'name' => 'Outro departamento sintético', 'code' => 'OTHER-AUDIT']); $head = User::factory()->create(['tenant_id' => $r->tenant_id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); $this->actingAs($head)->get($url)->assertNotFound(); $foreign = receiptFixture(); $this->actingAs($foreign['issuer'])->get($url)->assertNotFound(); $this->actingAs($this->actor); require_once __DIR__.'/../Support/SupplyFixture.php'; supplyGrant($this->actor, ['requester']); DB::table('organization_memberships')->where('user_id', $this->actor->id)->update(['maintenance' => true]); $this->get($url)->assertForbidden(); DB::table('organization_memberships')->where('user_id', $this->actor->id)->update(['maintenance' => false, 'expires_at' => now()->subMinute()]); $this->get($url)->assertForbidden(); }); 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('notice consultation searches the full visible scope and reading preserves filters without deciding', function () { $requests = []; for ($i = 0; $i < 28; $i++) { $this->postJson('/requisicoes', [...$this->data, 'operation_key' => (string) Str::uuid()])->assertRedirect(); $r = Requisition::query()->latest('id')->firstOrFail(); $this->postJson('/requisicoes/'.$r->id.'/submeter', ['operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertRedirect(); $requests[] = $r->fresh(); } $old = $requests[0]; $notice = DB::table('workflow_notices')->where('requisition_id', $old->id)->sole(); $before = []; foreach (['requisitions', 'approval_steps', 'budget_entries', 'stock_movements', 'workflow_notices', 'audit_logs'] as $table) { $before[$table] = DB::table($table)->orderBy('id')->get()->toArray(); } $this->actingAs($this->head)->get('/requisicoes/avisos?view=tasks&search=req-&page=2')->assertOk() ->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (AssertableInertia $p) => $p ->where('notices.total', 28)->has('notices.data', 3)->where('unread', 28) ->where('notices.prev_page_url', fn ($url) => str_contains($url, 'search=req-') && str_contains($url, 'view=tasks'))); $path = '/requisicoes/avisos?view=unread&search='.urlencode(mb_strtolower($old->number)).'&page=999'; $this->get($path)->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 1)->where('notices.current_page', 1) ->where('notices.data.0.id', $notice->id)->where('unread', 28)); $this->get('/requisicoes/avisos?search='.urlencode("' OR 1=1 --"))->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0)->where('unread', 28)); foreach ($before as $table => $rows) { expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows); } $filters = ['view' => 'unread', 'page' => 2, 'search' => $old->number]; $redirect = '/requisicoes/avisos?'.http_build_query($filters); $this->post('/requisicoes/avisos/'.$notice->id.'/ler', $filters)->assertRedirect($redirect); $this->post('/requisicoes/avisos/'.$notice->id.'/ler', $filters)->assertRedirect($redirect); $this->get($redirect)->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0)->where('notices.current_page', 1)->where('unread', 27)); foreach (['requisitions', 'approval_steps', 'budget_entries', 'stock_movements'] as $table) { expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($before[$table]); } expect(DB::table('audit_logs')->where('action', 'workflow.notice_read')->count())->toBe(1); $this->actingAs($this->actor)->get($path)->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0)); }); test('notice consultation rejects invalid context before recording a read', 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(); $notice = DB::table('workflow_notices')->where('requisition_id', $r->id)->sole(); $this->actingAs($this->head); foreach ([['search' => str_repeat('A', 101)], ['page' => 0], ['view' => 'invalid']] as $filters) { $this->getJson('/requisicoes/avisos?'.http_build_query($filters))->assertUnprocessable(); $this->postJson('/requisicoes/avisos/'.$notice->id.'/ler', $filters)->assertUnprocessable(); } expect(DB::table('workflow_notices')->where('id', $notice->id)->value('read_at'))->toBeNull(); expect(DB::table('audit_logs')->where('action', 'workflow.notice_read')->count())->toBe(0); }); 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'); });