Files
compras/tests/Feature/WarehouseExchangeTest.php
2026-11-02 20:27:11 +02:00

213 lines
14 KiB
PHP

<?php
use App\Models\CriticalityLevel;
use App\Models\Department;
use App\Models\Equipment;
use App\Models\User;
use App\Models\WarehouseRequestEvent;
use App\Services\OrganizationAccess;
use App\Services\ReceiptWorkflow;
use App\Services\WarehouseExchange;
use App\Services\WarehouseFulfilment;
use App\Services\WarehouseRequests;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tempo\Integration\WarehouseInbox;
require_once __DIR__.'/../Support/ReceiptFixture.php';
require_once __DIR__.'/../Support/SupplyFixture.php';
require_once __DIR__.'/../Support/InboxFixture.php';
beforeEach(function () {
$this->f = receiptFixture('100.000');
$this->reviewer = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['issuer']->department_id]);
supplyGrant($this->f['issuer'], ['warehouse_request_prepare']);
supplyGrant($this->f['receiver'], ['warehouse', 'warehouse_request_deliver']);
supplyGrant($this->reviewer, ['warehouse_request_review']);
$this->actingAs($this->f['receiver']);
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
$this->actingAs($this->f['issuer']);
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], ['operation_key' => (string) Str::uuid(),
'warehouse_id' => $this->f['warehouse']->id, 'cost_center_id' => $this->f['r']->cost_center_id,
'destination' => 'Oficina interna', 'reason' => 'Material para manutenção interna.', 'priority' => 'normal',
'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '4.000', 'use' => 'internal', 'equipment_id' => null]]]);
});
function exchangeCommand($test, $actor, string $action, array $extra = []): WarehouseRequestEvent
{
$test->actingAs($actor);
return app(WarehouseFulfilment::class)->command($actor, $test->r->id, [
'operation_key' => (string) Str::uuid(), 'version' => app(WarehouseFulfilment::class)->progress($test->r)['version'],
'action' => $action, 'reason' => 'Intervenção sintética do ensaio de integração.', ...$extra]);
}
function exchangeDeliver($test, string $quantity = '1.500'): WarehouseRequestEvent
{
exchangeCommand($test, $test->f['issuer'], 'submit', ['partial_allowed' => true]);
exchangeCommand($test, $test->reviewer, 'authorize', ['partial_allowed' => true]);
return exchangeCommand($test, $test->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => $quantity]]]);
}
test('warehouse exchange publishes only physical facts with original identifiers and stable replay content', function () {
$url = '/stock/integracao/entregas';
$this->getJson($url)->assertOk()->assertJsonCount(0, 'items');
$delivery = exchangeDeliver($this);
$first = $this->getJson($url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')
->assertJsonPath('schema', WarehouseExchange::SCHEMA)->assertJsonCount(1, 'items')
->assertJsonPath('items.0.lines.0.quantity_milli', 1500)
->assertJsonPath('items.0.lines.0.stock_operation_id', $delivery->data['lines'][0]['stock_operation_id'])
->assertJsonPath('items.0.request.document_type', 'internal_warehouse_request')
->assertJsonPath('items.0.request.destination', 'Oficina interna')->json('items.0');
$payload = $first;
unset($payload['payload_sha256']);
expect(hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)))->toBe($first['payload_sha256']);
// Current register changes must not rewrite the original item or requester.
$this->f['stock']->update(['name' => 'Nome posterior do cadastro']);
$this->f['issuer']->update(['name' => 'Nome posterior da pessoa']);
exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
$response = $this->getJson($url)->assertOk()->assertJsonCount(2, 'items');
expect($response->json('items.0'))->toBe($first);
$response->assertJsonPath('items.1.action', 'receive')->assertJsonCount(0, 'items.1.lines')
->assertJsonPath('items.1.delivery_event_key', $first['idempotency_key']);
});
test('warehouse exchange resumes old pending pages and later polls without skipping or duplicating facts', function () {
$delivery = exchangeDeliver($this);
exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
$first = $this->getJson('/stock/integracao/entregas?limit=1')->assertOk()->assertJsonPath('has_more', true)->json();
$next = '/stock/integracao/entregas?limit=1&cursor='.urlencode($first['next_cursor']);
exchangeCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '2.500']]]);
$this->actingAs($this->f['issuer']);
$second = $this->getJson($next)->assertOk()->assertJsonPath('items.0.action', 'receive')->assertJsonPath('has_more', true)->json();
expect($second['items'][0]['event_id'])->toBeGreaterThan($first['items'][0]['event_id']);
$third = $this->getJson('/stock/integracao/entregas?cursor='.urlencode($second['next_cursor']))->assertOk()->assertJsonCount(1, 'items')->assertJsonPath('has_more', false)->json();
$empty = $this->getJson('/stock/integracao/entregas?cursor='.urlencode($third['next_cursor']))->assertOk()->assertJsonCount(0, 'items')->json();
$last = WarehouseRequestEvent::query()->where('action', 'deliver')->latest('id')->firstOrFail();
exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $last->id]);
$this->getJson('/stock/integracao/entregas?cursor='.urlencode($empty['next_cursor']))->assertOk()->assertJsonCount(1, 'items')->assertJsonPath('items.0.action', 'receive');
});
test('warehouse exchange reads leave business ledgers profiles and audit unchanged', function () {
exchangeDeliver($this);
$tables = ['stock_movements', 'stock_operations', 'warehouse_reservations', 'warehouse_requests', 'warehouse_request_events', 'audit_logs', 'requisitions', 'organization_memberships'];
$before = [];
foreach ($tables as $table) {
$before[$table] = DB::table($table)->get()->toArray();
}
$this->getJson('/stock/integracao/entregas')->assertOk();
$this->getJson('/stock/integracao/entregas')->assertOk();
foreach ($tables as $table) {
expect(DB::table($table)->get()->toArray())->toEqual($before[$table]);
}
});
test('warehouse exchange rejects corrupt cross user cursors invalid limits and revoked access', function () {
exchangeDeliver($this);
$cursor = $this->getJson('/stock/integracao/entregas')->assertOk()->json('next_cursor');
$this->getJson('/stock/integracao/entregas?cursor=invalid')->assertUnprocessable()->assertJsonValidationErrors('cursor');
$this->getJson('/stock/integracao/entregas?limit=101')->assertUnprocessable();
$this->getJson('/stock/integracao/entregas?limit=0')->assertUnprocessable();
$this->actingAs($this->f['issuer'])->getJson('/stock/integracao/entregas?cursor='.urlencode($cursor))->assertUnprocessable();
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->delete();
$this->getJson('/stock/integracao/entregas')->assertForbidden();
});
test('warehouse exchange respects department ownership and tenant boundaries', function () {
exchangeDeliver($this);
$department = Department::create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento']);
$outsider = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id]);
supplyGrant($outsider, ['warehouse_request_review', 'warehouse_request_prepare']);
$this->actingAs($outsider)->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
$this->actingAs($this->reviewer)->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(1, 'items');
$foreign = receiptFixture('50.000');
supplyGrant($foreign['issuer'], ['admin']);
$this->actingAs($foreign['issuer'])->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
});
test('warehouse exchange requires an authenticated account with functional access', function () {
auth('web')->logout();
$this->getJson('/stock/integracao/entregas')->assertUnauthorized();
$this->actingAs($this->f['otherReceiver'])->getJson('/stock/integracao/entregas')->assertForbidden();
});
test('warehouse exchange rejects old cursors when functional scope expands and publishes no mere reservation', function () {
exchangeCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
exchangeCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
$response = $this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
$cursor = $response->json('next_cursor');
supplyGrant($this->reviewer, ['warehouse_request_read']);
$this->getJson('/stock/integracao/entregas?cursor='.urlencode($cursor))->assertUnprocessable()->assertJsonValidationErrors('cursor');
$this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
});
test('warehouse exchange never presents a cancelled undelivered balance as another physical issue', function () {
$delivery = exchangeDeliver($this);
exchangeCommand($this, $this->reviewer, 'cancel');
$this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(1, 'items')->assertJsonPath('items.0.event_id', $delivery->id);
exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
$this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(2, 'items')->assertJsonPath('items.1.action', 'receive');
});
test('warehouse exchange refuses another context and invalidates cursors after context activation', function () {
exchangeDeliver($this);
$actor = $this->f['issuer'];
$this->actingAs($actor);
$legacyCursor = $this->getJson('/stock/integracao/entregas')->assertOk()->json('next_cursor');
$this->f['tenant']->update(['rules_provisional' => false, 'work_context_policy' => ['revision' => 1, 'enabled' => ['procurement.internal', 'people.internal']]]);
config(['work_context.entitlements.'.$actor->tenant_id => ['procurement.internal', 'people.internal']]);
supplyGrant($actor, ['admin']);
DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->update(['work_contexts' => json_encode(['procurement.internal', 'people.internal'])]);
$this->post('/contexto', ['area' => 'people', 'option' => 'people.internal'])->assertRedirect();
$this->getJson('/stock/integracao/entregas')->assertForbidden();
$this->post('/contexto', ['area' => 'procurement', 'option' => 'procurement.internal'])->assertRedirect();
$this->getJson('/stock/integracao/entregas?cursor='.urlencode($legacyCursor))->assertUnprocessable();
$this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(1, 'items')
->assertJsonPath('items.0.request.work_context_key', 'procurement.internal');
});
test('warehouse exchange preserves the equipment selected at the request origin', function () {
$level = CriticalityLevel::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id,
'code' => 'NORMAL', 'name' => 'Normal', 'criteria' => 'Critério sintético.', 'is_critical' => false, 'created_at' => now()]);
$equipment = Equipment::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id,
'department_id' => $this->f['issuer']->department_id, 'criticality_level_id' => $level->id,
'code' => 'EQ-1', 'description' => 'Máquina da oficina', 'area' => 'Oficina', 'status' => 'active']);
$this->actingAs($this->f['issuer']);
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], ['operation_key' => (string) Str::uuid(),
'warehouse_id' => $this->f['warehouse']->id, 'cost_center_id' => $this->f['r']->cost_center_id,
'destination' => 'Oficina interna', 'reason' => 'Material para manutenção interna.', 'priority' => 'normal',
'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '4.000', 'use' => 'equipment', 'equipment_id' => $equipment->id]]]);
exchangeDeliver($this, '4.000');
$equipment->update(['description' => 'Descrição futura']);
$this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonPath('items.0.lines.0.equipment.id', $equipment->id)
->assertJsonPath('items.0.lines.0.equipment.description', 'Máquina da oficina')->assertJsonPath('items.0.lines.0.use', 'equipment');
});
test('warehouse delivery feed is accepted by an independent durable receiver without another stock movement', function () {
$delivery = exchangeDeliver($this);
exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
$before = DB::table('stock_movements')->count();
$db = inboxDatabase();
$inbox = new WarehouseInbox($db, 'synthetic-source', $this->f['tenant']->id, 99, 'synthetic-consumer');
$checkpoint = $inbox->checkpoint();
$first = $this->getJson('/stock/integracao/entregas?limit=1')->assertOk()->json();
$accepted = $inbox->accept($first, $checkpoint['version'], $checkpoint['cursor']);
expect($accepted['inserted'])->toBe(1)->and($accepted['has_more'])->toBeTrue();
$checkpoint = $inbox->checkpoint();
$second = $this->getJson('/stock/integracao/entregas?cursor='.urlencode($checkpoint['cursor']))->assertOk()->json();
$accepted = $inbox->accept($second, $checkpoint['version'], $checkpoint['cursor']);
expect($accepted['inserted'])->toBe(1)->and($accepted['has_more'])->toBeFalse()
->and($inbox->accept($second, $checkpoint['version'], $checkpoint['cursor'])['replayed'])->toBeTrue()
->and($inbox->fact($first['items'][0]['idempotency_key']))->toBe($first['items'][0])
->and(DB::table('stock_movements')->count())->toBe($before);
});
test('warehouse feed cursor stays bounded even for a profile containing the whole permission catalogue', function () {
supplyGrant($this->f['issuer'], array_keys(OrganizationAccess::PERMISSIONS));
$this->actingAs($this->f['issuer']);
$cursor = $this->getJson('/stock/integracao/entregas')->assertOk()->json('next_cursor');
expect(strlen($cursor))->toBeLessThanOrEqual(4096);
$this->getJson('/stock/integracao/entregas?cursor='.urlencode($cursor))->assertOk()->assertJsonCount(0, 'items');
});