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'); });