Files
SGF/tests/Feature/StockReadTest.php
2026-11-02 20:29:34 +02:00

235 lines
21 KiB
PHP

<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\DB;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
#[\PHPUnit\Framework\Attributes\Group('modular-focused')]
final class StockReadTest extends TestCase
{
private array $f;
private string $schema;
private string $key;
private ?string $originalRightsConstraint = null;
private array $qaActors = [];
protected function tearDown(): void
{
try {
if ($this->originalRightsConstraint !== null) DB::transaction(function () {
DB::table('permission_grants')->whereIn('user_id',$this->qaActors)->where('permission','stock.view')->delete();
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
DB::statement('ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check '.$this->originalRightsConstraint);
});
} finally { parent::tearDown(); }
}
private function fixture(int $n): void
{
$run = (string) getenv('SGF_ISOLATED_REGRESSION_RUN');
$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D',$run);
$db = DB::selectOne("SELECT current_database() AS name,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
$this->assertSame('sgf_tempo_test',$db->name); $this->assertSame('sgf_test_runner',$db->role);
$this->assertSame(55439,(int)$db->port); $this->assertSame('sgf_modular_regression_'.$run,$db->cluster);
if ($this->originalRightsConstraint === null) {
$this->originalRightsConstraint = DB::selectOne("SELECT pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->definition;
DB::transaction(function () {
$old=substr($this->originalRightsConstraint,6);
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (($old) OR (permission='stock.view' AND unit_id IS NOT NULL))");
});
}
$this->schema = 'sgf_stock_read_'.bin2hex(random_bytes(6)); $s=$this->schema;
DB::transaction(function () use ($n,$s) {
$this->f=IdentityFixture::create('-stock-'.bin2hex(random_bytes(5)));
$this->qaActors[]=$this->f['manager']->id;
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'stock.view',$this->f['unit']);
DB::statement("CREATE SCHEMA $s");
DB::statement("CREATE TABLE $s.stock_items(id bigint PRIMARY KEY,tenant_id bigint NOT NULL,code text NOT NULL,name text NOT NULL,unit text NOT NULL)");
DB::statement("CREATE TABLE $s.warehouses(id bigint PRIMARY KEY,tenant_id bigint NOT NULL,code text NOT NULL,name text NOT NULL)");
DB::statement("CREATE TABLE $s.stock_movements(id bigserial PRIMARY KEY,tenant_id bigint NOT NULL,stock_item_id bigint NOT NULL,warehouse_id bigint NOT NULL,quantity_milli bigint NOT NULL,movement_type text NOT NULL,snapshot jsonb NOT NULL,moved_at timestamp NOT NULL)");
DB::statement("INSERT INTO $s.warehouses VALUES(11,77,'WH-A','Synthetic A'),(12,77,'WH-B','Synthetic B'),(13,77,'HIDDEN','Restricted warehouse'),(21,88,'FOREIGN','Foreign warehouse')");
DB::statement("INSERT INTO $s.stock_items SELECT i,77,'QA-'||lpad(i::text,6,'0'),'Synthetic item '||i,'un' FROM generate_series(1,$n) i");
DB::statement("INSERT INTO $s.stock_items VALUES($n+1,77,'ZERO','Without movements','un'),(9999999,88,'SECRET','Foreign item','un')");
DB::statement("INSERT INTO $s.stock_movements(tenant_id,stock_item_id,warehouse_id,quantity_milli,movement_type,snapshot,moved_at)
SELECT 77,i,11,10000,'receipt',jsonb_build_object('reference','QA-R-'||i,'private_document','DO_NOT_RETURN'),'2026-11-01'::timestamp FROM generate_series(1,$n) i");
DB::statement("INSERT INTO $s.stock_movements(tenant_id,stock_item_id,warehouse_id,quantity_milli,movement_type,snapshot,moved_at)
SELECT 77,i,11,-2000,'issue',jsonb_build_object('reference','QA-I-'||i),'2026-11-02'::timestamp FROM generate_series(1,$n) i");
DB::statement("INSERT INTO $s.stock_movements(tenant_id,stock_item_id,warehouse_id,quantity_milli,movement_type,snapshot,moved_at) VALUES
(77,1,13,99000,'hidden','{}',now()),(88,9999999,21,88000,'foreign','{}',now())");
DB::statement("CREATE FUNCTION $s.immutable() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''Inventory ledger is append-only''; END'");
DB::statement("CREATE TRIGGER immutable BEFORE UPDATE OR DELETE ON $s.stock_movements FOR EACH ROW EXECUTE FUNCTION $s.immutable()");
});
$this->key=$this->f['tenant'].':'.$this->f['company'].':'.$this->f['unit'];
config(['stock_read.synthetic_sources.'.$this->key=>['schema'=>$s,'source_tenant_id'=>77,'warehouse_ids'=>[11,12],'append_only'=>true],
'modules.companies.'.$this->f['tenant'].':'.$this->f['company']=>['contracted'=>['stock'],'enabled'=>['stock']]]);
$this->actingAs($this->f['manager'])->withSession(['auth_version'=>$this->f['manager']->auth_version]);
$token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit'],'module_id'=>'stock'])->assertOk()->json('context.token');
$this->withHeader('X-SGF-Context',$token);
}
public function test_scoped_exact_reading_and_common_projection_equivalence(): void
{
$this->fixture(30);
foreach (['items'=>31,'warehouses'=>2,'balances'=>30,'movements'=>60] as $view=>$total) {
$r=$this->getJson('/api/stock-read?view='.$view)->assertOk()->assertJsonPath('pagination.total',$total)->assertJsonPath('capabilities.write',false);
$this->assertStringNotContainsString('DO_NOT_RETURN',$r->getContent()); $this->assertStringNotContainsString('Foreign',$r->getContent()); $this->assertStringNotContainsString('Restricted warehouse',$r->getContent());
$direct=DB::transaction(function () use ($view) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
return (new \Tempo\Inventory\InventoryReader(DB::connection(),$this->schema,77,[11,12]))->page(['view'=>$view,'page'=>1,'per_page'=>25]);
});
$this->assertSame(json_decode(json_encode($direct['rows']),true),$r->json('rows'));
}
$this->getJson('/api/stock-read')->assertOk()->assertJsonPath('rows.0.physical_milli','8000')->assertJsonPath('rows.0.available_milli',null);
$this->getJson('/api/stock-read?view=items&search=ZERO')->assertOk()->assertJsonPath('pagination.total',1);
$this->getJson('/api/stock-read?warehouse_id=12')->assertOk()->assertJsonPath('pagination.total',0);
$this->getJson('/api/stock-read?view=items&search=%25')->assertOk()->assertJsonPath('pagination.total',0);
$this->getJson('/api/stock-read?warehouse_id=13')->assertForbidden();
$this->getJson('/api/stock-read?warehouse_id=21')->assertForbidden();
$this->getJson('/api/stock-read?per_page=101')->assertUnprocessable();
$cut=$this->getJson('/api/stock-read')->assertOk()->json('snapshot');
$this->getJson('/api/stock-read?warehouse_id=12&snapshot='.urlencode($cut))->assertConflict();
$this->getJson('/api/stock-read?tenant_id=88')->assertUnprocessable();
$this->postJson('/api/stock-read',['quantity'=>1])->assertStatus(405);
$this->withHeader('X-SGF-Context','old-context')->getJson('/api/stock-read')->assertConflict();
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit'],'module_id'=>'stock'])->assertForbidden();
$this->actingAs($this->f['reader'])->withSession(['auth_version'=>$this->f['reader']->auth_version]);
$this->getJson('/api/stock-read')->assertForbidden();
}
public function test_snapshot_invalidation_context_revocation_and_unavailable_source(): void
{
$this->fixture(30);
$cut=$this->getJson('/api/stock-read')->assertOk()->json('snapshot');
$this->getJson('/api/stock-read?page=2&snapshot='.urlencode($cut))->assertOk()->assertJsonPath('rows.0.stock_item_id',26);
DB::table($this->schema.'.stock_movements')->insert(['tenant_id'=>77,'stock_item_id'=>1,'warehouse_id'=>11,'quantity_milli'=>-1000,'movement_type'=>'issue','snapshot'=>'{}','moved_at'=>now()]);
$this->getJson('/api/stock-read?page=2&snapshot='.urlencode($cut))->assertConflict();
$new=$this->getJson('/api/stock-read')->assertOk()->assertJsonPath('rows.0.physical_milli','7000')->json('snapshot');
DB::table($this->schema.'.stock_items')->where('id',1)->update(['name'=>'Synthetic renamed']);
$this->getJson('/api/stock-read?page=2&snapshot='.urlencode($new))->assertConflict();
$this->getJson('/api/stock-read?snapshot=invalid')->assertConflict();
$this->getJson('/api/stock-read')->assertOk();
$binding=config('stock_read.synthetic_sources.'.$this->key);
config(['stock_read.synthetic_sources.'.$this->key=>array_replace($binding,['warehouse_ids'=>[999999]])]);
$this->getJson('/api/stock-read')->assertStatus(503);
config(['stock_read.synthetic_sources.'.$this->key=>array_replace($binding,['schema'=>'sgf_stock_read_000000000000'])]);
$this->getJson('/api/stock-read')->assertStatus(503)->assertJsonPath('status','unavailable')->assertJsonMissingPath('exception');
config(['stock_read.synthetic_sources.'.$this->key=>$binding]);
$this->getJson('/api/stock-read')->assertOk();
config(['stock_read.synthetic_sources.'.$this->key=>null]);
$this->getJson('/api/stock-read')->assertStatus(503)->assertJsonPath('status','unavailable')->assertJsonMissingPath('rows');
DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('permission','stock.view')->delete();
$this->getJson('/api/stock-read')->assertForbidden();
}
public function test_volume_pages_query_count_and_exact_coverage(): void
{
$report=['date'=>'2026-11-02','kind'=>'new_inventory_reader_not_association_rebenchmark','profiles'=>[],
'source_composition'=>'SGF adapter and common reader in isolated regression only; Purchasing HTTP composition not yet connected',
'indexes'=>'Primary keys only; no index experiment','fixtures'=>'New QA-only schemas and identities retained; no reset or protected-record changes'];
$stats=null;DB::listen(function ($q) use (&$stats) {if($stats!==null){$stats['queries']++;$stats['database_ms']+=$q->time;}});
foreach ([1000,5000,10000] as $n) {
$this->fixture($n);
foreach (['items','warehouses','balances','movements'] as $view) foreach ([25,100] as $size) {
$url='/api/stock-read?view='.$view.'&per_page='.$size; $this->getJson($url)->assertOk();$samples=[];
for($i=0;$i<3;$i++) {
gc_collect_cycles();memory_reset_peak_usage();$memory=memory_get_usage(false);$stats=['queries'=>0,'database_ms'=>0.0];$start=hrtime(true);
$r=$this->getJson($url);$elapsed=(hrtime(true)-$start)/1e6;$sample=$stats;$stats=null;
$r->assertOk()->assertJsonPath('pagination.total',match($view){'items'=>$n+1,'warehouses'=>2,'balances'=>$n,'movements'=>$n*2});
$sample+=['elapsed_ms'=>round($elapsed,3),'incremental_php_bytes'=>max(0,memory_get_peak_usage(false)-$memory),'peak_php_bytes'=>memory_get_peak_usage(false),'response_bytes'=>strlen($r->getContent())];$samples[]=$sample;
}
$counts=array_unique(array_column($samples,'queries'));$this->assertCount(1,$counts);$this->assertLessThan(35,$counts[0]);
$times=array_column($samples,'elapsed_ms');sort($times);
$report['profiles'][]=['records'=>$n,'view'=>$view,'page_size'=>$size,'median_ms'=>$times[1],'samples'=>$samples];
}
$cut=null;$ids=[];
for($page=1;$page<=ceil($n/100);$page++){
$url='/api/stock-read?view=balances&per_page=100&page='.$page.($cut?'&snapshot='.urlencode($cut):'');
$r=$this->getJson($url)->assertOk();$cut??=$r->json('snapshot');$ids=array_merge($ids,array_column($r->json('rows'),'stock_item_id'));
}
$this->assertSame(range(1,$n),$ids);
}
$output=getenv('SGF_STOCK_READ_OUTPUT');
if($output){
$this->assertSame(realpath(base_path('..')),realpath(dirname($output)));
$report['source_sha256']=hash_file('sha256',base_path('packages/inventory-read/src/InventoryReader.php'));
$report['controller_sha256']=hash_file('sha256',base_path('app/Modules/Inventory/StockReadController.php'));
file_put_contents($output,json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL);
}
}
public function test_purchasing_composition_matches_authenticated_sgf_pages(): void
{
$input=getenv('STOCK_READ_NATIVE_PROOF');
if(!$input)$this->markTestSkipped('Use the isolated two-composition launcher.');
$private=realpath(base_path('../private'));$file=realpath($input);
$this->assertTrue($file!==false&&str_starts_with($file,$private.DIRECTORY_SEPARATOR));
$native=json_decode(file_get_contents($file),true,512,JSON_THROW_ON_ERROR);
$this->assertTrue($native['native_roles_and_profiles_revalidated']);$this->assertTrue($native['warehouse_isolation']);
$this->assertCount(12,$native['cases']);$this->fixture(100);$cases=[];
foreach($native['cases'] as $query=>$expected){
$r=$this->getJson('/api/stock-read?'.$query)->assertOk();
$actual=hash('sha256',json_encode([$r->json('rows'),$r->json('pagination')],JSON_THROW_ON_ERROR));
$this->assertSame($expected['sha256'],$actual,'Both authenticated host adapters must return identical scoped rows and pagination');
$this->assertSame($expected['total'],$r->json('pagination.total'));
$cases[$query]=['sha256'=>$actual,'total'=>$expected['total'],'equivalent'=>true];
}
$this->getJson('/api/stock-read?snapshot='.urlencode($native['native_snapshot']))->assertConflict();
$output=getenv('SGF_STOCK_READ_OUTPUT');$this->assertSame(realpath(base_path('..')),realpath(dirname($output)));
file_put_contents($output,json_encode(['kind'=>'two_authenticated_http_kernels','cases'=>$cases,'cross_host_token_refused'=>true,
'reader_sha256'=>hash_file('sha256',base_path('packages/inventory-read/src/InventoryReader.php')),
'session_sha256'=>hash_file('sha256',base_path('packages/inventory-read/src/InventoryReadSession.php')),
'native_package_manifest_sha256'=>$native['package_manifest_sha256'],'network_login_browser_tested'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
}
public function test_native_receipt_book_is_observed_without_a_second_stock_effect(): void
{
$root=realpath((string)getenv('STOCK_RECEIPT_NATIVE_ROOT'));
if(!$root)$this->markTestSkipped('Use the receipt integration launcher.');
$this->assertStringStartsWith(realpath(base_path('../private')).DIRECTORY_SEPARATOR,$root);
$this->assertMatchesRegularExpression('/^stock-receipt-[a-f0-9]{12}$/D',basename($root));
$native=json_decode(file_get_contents($root.'/receipt-results.json'),true,512,JSON_THROW_ON_ERROR);
$source=json_decode(file_get_contents($root.'/source-connection.json'),true,512,JSON_THROW_ON_ERROR);
$this->assertTrue($native['fault_atomic_and_recovered']);$this->assertTrue($native['concurrency']['one_movement']);
$this->fixture(1);
config(['database.connections.stock_receipt_source'=>$source,'stock_read.synthetic_sources.'.$this->key=>[
'connection'=>'stock_receipt_source','schema'=>'public','source_tenant_id'=>$native['link']['tenant'],'warehouse_ids'=>[$native['link']['warehouse']],'append_only'=>true]]);
DB::purge('stock_receipt_source');$connection=DB::connection('stock_receipt_source');
$sourceBefore=$connection->table('stock_movements')->count();$localBefore=DB::table($this->schema.'.stock_movements')->count();
$r=$this->getJson('/api/stock-read')->assertOk()->assertJsonPath('pagination.total',1)
->assertJsonPath('rows.0.physical_milli','4000')->assertJsonPath('rows.0.available_milli',null)->assertJsonPath('capabilities.write',false);
$this->assertSame($native['link']['item'],$r->json('rows.0.stock_item_id'));
$this->getJson('/api/stock-read?view=movements')->assertOk()->assertJsonPath('pagination.total',1)
->assertJsonPath('rows.0.id',$native['link']['movement'])->assertJsonPath('rows.0.source_reference',$native['link']['reference']);
$this->getJson('/api/stock-read?warehouse_id=9999999')->assertForbidden();
try{$connection->statement('DELETE FROM public.stock_movements WHERE false');$this->fail('Source reader must not have write access');}
catch(\Illuminate\Database\QueryException $e){$this->assertContains($e->errorInfo[0],['25006','42501']);}
$this->getJson('/api/stock-read')->assertOk();
$this->assertSame($sourceBefore,$connection->table('stock_movements')->count());$this->assertSame($localBefore,DB::table($this->schema.'.stock_movements')->count());
DB::purge('stock_receipt_source');config(['database.connections.stock_receipt_source'=>array_replace($source,['password'=>'synthetic-invalid-password'])]);
$this->getJson('/api/stock-read')->assertStatus(503)->assertJsonPath('status','unavailable')->assertJsonMissingPath('exception')->assertJsonMissingPath('rows');
DB::purge('stock_receipt_source');config(['database.connections.stock_receipt_source'=>$source]);
$this->getJson('/api/stock-read')->assertOk();
$output=getenv('SGF_STOCK_READ_OUTPUT');$this->assertSame(realpath(base_path('..')),realpath(dirname($output)));
file_put_contents($output,json_encode(['kind'=>'native_receipt_to_shared_writer_to_sgf_read','physical_milli'=>'4000','rejected_milli'=>'2000',
'same_native_movement_observed'=>true,'source_write_refused'=>true,'native_book_unchanged_by_read'=>true,'sgf_book_unchanged_by_read'=>true,'source_connection_failure_503_and_recovery'=>true,
'source_read_role'=>$source['username'],'writer_manifest_sha256'=>$native['writer_manifest_sha256'],'no_sgf_receipt_command_or_ui'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
DB::disconnect('stock_receipt_source');
}
public function test_native_return_book_is_observed_without_copy_or_write():void
{
$root=realpath((string)getenv('STOCK_RETURN_NATIVE_ROOT'));if(!$root)$this->markTestSkipped('Use the native return observer launcher.');
$this->assertStringStartsWith(realpath(base_path('../private')).DIRECTORY_SEPARATOR,$root);$this->assertMatchesRegularExpression('/^stock-receipt-[a-f0-9]{12}$/D',basename($root));
$native=json_decode(file_get_contents($root.'/return-results.json'),true,512,JSON_THROW_ON_ERROR);$source=json_decode(file_get_contents($root.'/source-connection.json'),true,512,JSON_THROW_ON_ERROR);$this->fixture(1);
config(['database.connections.stock_receipt_source'=>$source,'stock_read.synthetic_sources.'.$this->key=>['connection'=>'stock_receipt_source','schema'=>'public','source_tenant_id'=>$native['tenant'],'warehouse_ids'=>[$native['warehouse']],'append_only'=>true]]);DB::purge('stock_receipt_source');$connection=DB::connection('stock_receipt_source');
$before=$connection->table('stock_movements')->where('tenant_id',$native['tenant'])->count();$local=DB::table($this->schema.'.stock_movements')->count();
$this->getJson('/api/stock-read')->assertOk()->assertJsonPath('rows.0.physical_milli','8000')->assertJsonPath('rows.0.stock_item_id',$native['item'])->assertJsonPath('capabilities.write',false);
$moves=$this->getJson('/api/stock-read?view=movements')->assertOk()->assertJsonPath('pagination.total',3)->json('rows');$returned=collect($moves)->firstWhere('id',$native['return_movement']);$this->assertNotNull($returned);$this->assertSame('3000',$returned['quantity_milli']);$this->assertSame('workshop_return_in',$returned['movement_type']);
$this->getJson('/api/stock-read?warehouse_id=9999999')->assertForbidden();try{$connection->statement('DELETE FROM public.stock_movements WHERE false');$this->fail('Read-only role accepted write');}catch(\Illuminate\Database\QueryException $e){$this->assertContains($e->errorInfo[0],['25006','42501']);}
$this->assertSame(3,$before);$this->assertSame($before,$connection->table('stock_movements')->where('tenant_id',$native['tenant'])->count());$this->assertSame($local,DB::table($this->schema.'.stock_movements')->count());
$output=getenv('SGF_STOCK_READ_OUTPUT');$this->assertSame(realpath(base_path('..')),realpath(dirname($output)));file_put_contents($output,json_encode(['kind'=>'native_return_same_book_sgf_observer','physical_milli'=>'8000','native_movements'=>3,'same_return_movement'=>true,'observed_return_movement_id'=>$native['return_movement'],'native_fixture_sha256'=>hash_file('sha256',$root.'/return-results.json'),'source_write_refused'=>true,'no_local_copy_or_second_effect'=>true,'return_manifest_sha256'=>$native['shared_return_manifest_sha256']],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
}
}