This commit is contained in:
2026-11-02 20:29:34 +02:00
parent 340f3bb5fa
commit fcf31e8d48
181 changed files with 33383 additions and 51 deletions
@@ -167,4 +167,72 @@ final class ModuleAssociationReviewTest extends TestCase
}
}
}
public function test_page_batches_shared_dependencies_and_query_count_does_not_grow_per_row(): void {
[$first,$client,$service]=$this->draft();
$data=DB::table('a_service_drafts')->where('id',$first)->value('data');
for($i=0;$i<36;$i++)$this->record('service_drafts',['client_id'=>$client,'data'=>$data]);
for($i=0;$i<250;$i++)$this->record('clients',['name'=>'Outside dependency closure '.$i]);
$calls=0;$audit=0;$active=false;$clientFetches=[];
DB::listen(function($q)use(&$calls,&$audit,&$active,&$clientFetches){if($active){$calls++;if(str_contains($q->sql,'identity_audit'))$audit++;if(str_contains($q->sql,'from "a_clients"'))$clientFetches[]=[$q->sql,$q->bindings];}});
$active=true;$small=$this->getJson('/api/module-records?area=service_drafts&per_page=25');$active=false;
$small->assertOk()->assertJsonCount(25,'records.service_drafts');$smallCalls=$calls;$smallAudit=$audit;
$calls=0;$audit=0;$active=true;$large=$this->getJson('/api/module-records?area=service_drafts&per_page=100');$active=false;
$large->assertOk()->assertJsonPath('pagination.total',37)->assertJsonCount(37,'records.service_drafts');
$this->assertSame($smallCalls,$calls);$this->assertLessThanOrEqual(22,$calls);$this->assertSame(3,$smallAudit);$this->assertSame(3,$audit);
$this->assertCount(2,$clientFetches);
foreach($clientFetches as [$sql,$bindings]){
$this->assertMatchesRegularExpression('/"id" in \(\?\)/',$sql,'Only the referenced client is fetched, not the 250 unrelated clients');
$this->assertSame($client,end($bindings));
}
foreach($large->json('records.service_drafts') as $row){
$this->assertSame([['area'=>'clients','id'=>$client,'version'=>1,'modules'=>[],'revision'=>0],['area'=>'services','id'=>$service,'version'=>1,'modules'=>[],'revision'=>0]],$row['dependencies']);
}
}
public function test_batched_state_preserves_review_only_revision_and_multiple_module_transitions(): void {
$id=$this->record();
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'workshop.view',$this->f['unit']);
$review=$this->preview($id,'clients',['operation'=>'remove'])->assertOk()->json('review_token');
$event=$this->confirm($review)->assertOk()->json('event_id');
$this->getJson('/api/module-records?state=pending')->assertOk()->assertJsonPath('records.clients.0.modules',[])->assertJsonPath('records.clients.0.revision',$event);
foreach(['workshop','commercial'] as $module)$this->confirm($this->preview($id,'clients',['module_id'=>$module])->assertOk()->json('review_token'))->assertOk();
$this->getJson('/api/module-records?state=associated')->assertOk()->assertJsonPath('records.clients.0.modules',['commercial','workshop']);
$this->confirm($this->preview($id,'clients',['operation'=>'remove'])->assertOk()->json('review_token'))->assertOk();
$this->getJson('/api/module-records?state=associated')->assertOk()->assertJsonPath('records.clients.0.modules',['workshop']);
$noop=$this->preview($id,'clients',['module_id'=>'workshop'])->assertOk()->json('review_token');
$event=$this->confirm($noop)->assertOk()->json('event_id');$this->confirm($noop)->assertOk()->assertJsonPath('replayed',true);
$this->getJson('/api/module-records?state=associated')->assertOk()->assertJsonPath('records.clients.0.modules',['workshop'])->assertJsonPath('records.clients.0.revision',$event);
$this->confirm($this->preview($id,'clients',['module_id'=>'workshop','operation'=>'remove'])->assertOk()->json('review_token'))->assertOk();
$this->getJson('/api/module-records?state=removed')->assertOk()->assertJsonPath('records.clients.0.modules',[]);
}
public function test_page_cache_never_survives_a_request_and_confirmation_uses_fresh_dependencies(): void {
[$draft,$client]=$this->draft();
$this->getJson('/api/module-records?area=service_drafts')->assertOk()->assertJsonPath('records.service_drafts.0.dependencies_restricted',false);
$token=$this->preview($draft,'service_drafts')->assertOk()->json('review_token');
DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('company_id',$this->f['company'])->where('permission','clients.view')->delete();
$this->getJson('/api/module-records?area=service_drafts')->assertOk()->assertJsonPath('records.service_drafts.0.dependencies_restricted',true)->assertJsonPath('records.service_drafts.0.dependencies',[]);
$this->confirm($token)->assertForbidden();
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'clients.view');
DB::table('a_clients')->where('id',$client)->update(['version'=>2,'name'=>'Dependency changed after listing']);
$this->getJson('/api/module-records?area=service_drafts')->assertOk()->assertJsonPath('records.service_drafts.0.dependencies.0.version',2);
$this->confirm($token)->assertConflict();
}
public function test_metadata_projection_preserves_reference_order_and_full_content_revalidation(): void {
[$draft,$client,$service]=$this->draft();$second=$this->record('services');
$data=json_decode(DB::table('a_service_drafts')->where('id',$draft)->value('data'),true);
$line=$data['lines'][0];$other=$line;$other['service_id']=$second;
$data['lines']=[$other,$line,$other];$data['description']=str_repeat('DOCUMENT-BODY-',100);
DB::table('a_service_drafts')->where('id',$draft)->update(['version'=>2,'data'=>json_encode($data)]);
$billData=$data;$billData['source_version']=2;
$bill=$this->record('billing_drafts',['client_id'=>$client,'source_id'=>$draft,'data'=>json_encode($billData)]);
$r=$this->getJson('/api/module-records?area=billing_drafts')->assertOk()->assertJsonPath('records.billing_drafts.0.id',$bill);
$services=array_values(array_filter($r->json('records.billing_drafts.0.dependencies'),fn($d)=>$d['area']==='services'));
$this->assertSame([$second,$service],array_column($services,'id'));
$this->assertStringNotContainsString('DOCUMENT-BODY-',$r->getContent());
$this->assertArrayNotHasKey('association_service_ids',$r->json('records.billing_drafts.0'));
$token=$this->preview($bill,'billing_drafts')->assertOk()->json('review_token');
$data['description']='SOURCE-CONTENT-CHANGED-WITH-SAME-REFERENCES';
DB::table('a_service_drafts')->where('id',$draft)->update(['version'=>3,'data'=>json_encode($data)]);
$this->getJson('/api/module-records?area=billing_drafts')->assertOk()->assertJsonPath('records.billing_drafts.0.dependencies.1.version',3);
$this->confirm($token)->assertConflict();
}
}
+234
View File
@@ -0,0 +1,234 @@
<?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));
}
}
+379
View File
@@ -0,0 +1,379 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Modules\Identity\Services\Audit;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Carbon\CarbonImmutable;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
#[\PHPUnit\Framework\Attributes\Group('modular-focused')]
final class WorkshopStockTest extends TestCase
{
private array $f;private string $schema;private string $constraint;private string $bindingKey;private array $binding;private int $order;private int $task;private int $authorization;private int $entry;
protected function setUp(): void
{
parent::setUp();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D',$run);
$i=DB::selectOne("SELECT current_database() AS db,current_user AS role,current_setting('cluster_name') AS cluster,inet_server_port() AS port");
$this->assertSame('sgf_tempo_test',$i->db);$this->assertSame('sgf_test_runner',$i->role);$this->assertSame(55439,(int)$i->port);$this->assertSame('sgf_modular_regression_'.$run,$i->cluster);
$this->constraint=DB::selectOne("SELECT pg_get_constraintdef(oid) AS d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d;
DB::transaction(function(){
$old=substr($this->constraint,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 IN ('stock.view','workshop_stock.view','workshop_stock.prepare','workshop_stock.confirm') AND unit_id IS NOT NULL))");
$tag=bin2hex(random_bytes(6));$this->f=IdentityFixture::create('-ws-'.$tag);
foreach(['tech','reviewer'] as $name){$u=User::create(['name'=>'Synthetic '.$name,'email'=>$name.'-'.$tag.'@example.test','password'=>bin2hex(random_bytes(16))]);$this->f[$name]=$u;DB::table('company_memberships')->insert(['user_id'=>$u->id,'tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company']]);foreach(['company.view'=>null,'unit.view'=>$this->f['unit']] as $r=>$unit)IdentityFixture::grant($u->id,$this->f['tenant'],$this->f['company'],$r,$unit);}
foreach(['manager'=>['view','open','assign','authorize'],'tech'=>['view','record'],'reviewer'=>['view','verify'],'reader'=>['view']] as $name=>$rights)foreach($rights as $r)$this->grant($name,'workshop.'.$r);
foreach(['manager'=>['stock.view','workshop_stock.view','workshop_stock.prepare','workshop_stock.confirm'],'reader'=>['stock.view','workshop_stock.view','workshop_stock.confirm']] as $name=>$rights)foreach($rights as $r)$this->grant($name,$r);
$client=DB::table('a_clients')->insertGetId(['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>null,'name'=>'Synthetic stock workshop client','version'=>1,'data'=>'{}','created_at'=>now(),'updated_at'=>now()]);$this->f['client']=$client;
Audit::record('module.record.associated',$this->f['manager']->id,$this->f['tenant'],$this->f['company'],details:['area'=>'clients','record_id'=>$client,'module_id'=>'workshop','reason'=>'Explicit new synthetic fixture association']);
$this->schema='sgf_stock_read_'.$tag;$s=$this->schema;
DB::statement("CREATE SCHEMA $s");
DB::statement("CREATE TABLE $s.stock_items(id bigint PRIMARY KEY,tenant_id bigint,code text,name text,unit text)");DB::statement("INSERT INTO $s.stock_items VALUES(1,77,'CABO','Synthetic cable','UN')");
DB::statement("CREATE TABLE $s.warehouses(id bigint PRIMARY KEY,tenant_id bigint,code text,name text)");DB::statement("INSERT INTO $s.warehouses VALUES(11,77,'WH','Synthetic warehouse')");
DB::statement("CREATE TABLE $s.stock_operations(id bigserial PRIMARY KEY,tenant_id bigint,created_by bigint,responsible_id bigint,warehouse_id bigint,stock_item_id bigint,kind text,reference text,idempotency_key uuid,payload_hash text,destination text,reason text,quantity_milli bigint,theoretical_milli bigint,ledger_version bigint,recorded_at timestamp,snapshot jsonb,UNIQUE(tenant_id,reference),UNIQUE(tenant_id,idempotency_key))");
DB::statement("CREATE TABLE $s.stock_movements(id bigserial PRIMARY KEY,tenant_id bigint,stock_operation_id bigint,stock_item_id bigint,warehouse_id bigint,moved_by bigint,equipment_id bigint,spare_part_id bigint,movement_type text,quantity_milli bigint,snapshot jsonb,moved_at timestamp,UNIQUE(stock_operation_id,warehouse_id))");
DB::statement("INSERT INTO $s.stock_movements(tenant_id,stock_item_id,warehouse_id,movement_type,quantity_milli,snapshot,moved_at) VALUES(77,1,11,'receipt_in',10000,'{}',now()-interval '1 hour')");
DB::statement("CREATE TABLE $s.ws_proposals(id bigserial PRIMARY KEY,tenant_id bigint,company_id bigint,unit_id bigint,entry_id bigint,entry_version int,preparer_id bigint,command_key uuid,request_hash text,reason text,evidence text,source_hash text,snapshot jsonb,created_at timestamp,UNIQUE(tenant_id,company_id,unit_id,preparer_id,command_key))");
DB::statement("CREATE TABLE $s.ws_claims(id bigserial PRIMARY KEY,tenant_id bigint,company_id bigint,unit_id bigint,origin_key text UNIQUE,proposal_id bigint UNIQUE,entry_id bigint UNIQUE,entry_version int,operation_id bigint UNIQUE,movement_id bigint UNIQUE,preparer_id bigint,confirmer_id bigint,reason text,before_milli text,after_milli text,source_hash text,created_at timestamp)");
DB::statement("CREATE TABLE $s.ws_allocations(id bigserial PRIMARY KEY,tenant_id bigint,company_id bigint,unit_id bigint,origin_key text UNIQUE,proposal_id bigint UNIQUE REFERENCES $s.ws_proposals(id),entry_id bigint UNIQUE,entry_version int,operation_id bigint REFERENCES $s.stock_operations(id),movement_id bigint REFERENCES $s.stock_movements(id),quantity_milli bigint NOT NULL CHECK(quantity_milli>0),preparer_id bigint,confirmer_id bigint,reason text,before_milli text,after_milli text,source_hash text,created_at timestamp)");
DB::statement("CREATE INDEX ON $s.ws_allocations(movement_id)");
DB::statement("CREATE TABLE $s.ws_returns(id bigserial PRIMARY KEY,tenant_id bigint,company_id bigint,unit_id bigint,origin_key text UNIQUE,proposal_id bigint UNIQUE REFERENCES $s.ws_proposals(id),entry_id bigint UNIQUE,entry_version int,operation_id bigint UNIQUE REFERENCES $s.stock_operations(id),movement_id bigint UNIQUE REFERENCES $s.stock_movements(id),parent_movement_id bigint NOT NULL REFERENCES $s.stock_movements(id),quantity_milli bigint NOT NULL CHECK(quantity_milli>0),preparer_id bigint,confirmer_id bigint,reason text,before_milli text,after_milli text,source_hash text,created_at timestamp)");
DB::statement("CREATE INDEX ON $s.ws_returns(parent_movement_id)");
DB::statement("CREATE TABLE $s.ws_closures(id bigserial PRIMARY KEY,tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_id bigint NOT NULL,proposal_id bigint NOT NULL UNIQUE REFERENCES $s.ws_proposals(id),actor_id bigint NOT NULL,source_hash text NOT NULL,reason text NOT NULL,before_state text NOT NULL CHECK(before_state='pending'),state text NOT NULL CHECK(state IN ('withdrawn','rejected')),state_version int NOT NULL CHECK(state_version=2),created_at timestamp NOT NULL)");
DB::statement("CREATE INDEX ON $s.ws_closures(tenant_id,company_id,unit_id,state,proposal_id)");
DB::statement("CREATE FUNCTION $s.immutable() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''Append-only workshop stock proof''; END'");
foreach(['ws_proposals','ws_claims','ws_allocations','ws_returns','ws_closures','stock_operations','stock_movements'] as $table)DB::statement("CREATE TRIGGER immutable BEFORE UPDATE OR DELETE ON $s.$table FOR EACH ROW EXECUTE FUNCTION $s.immutable()");
});
config(['modules.companies.'.$this->f['tenant'].':'.$this->f['company']=>['contracted'=>['workshop','stock'],'enabled'=>['workshop','stock']]]);
CarbonImmutable::setTestNow(CarbonImmutable::now()->subMinutes(10)->startOfMinute());
$this->login('manager');$this->order=$this->withHeader('Idempotency-Key',(string)Str::uuid())->postJson('/api/workshop/orders',['reference'=>'QA-WS-'.Str::uuid(),'client_id'=>$this->f['client'],'equipment_identifier'=>'QA-EQ-'.Str::uuid(),'equipment_description'=>'Synthetic equipment','request'=>'Synthetic repair','location'=>'Synthetic place','evidence'=>'Synthetic work request'])->assertOk()->json('id');
$this->record('assignment',['technician_id'=>$this->f['tech']->id]);$this->login('tech');$this->task=$this->record('task',['description'=>'Synthetic authorised task']);
$this->login('manager');$this->authorization=$this->record('authorization',['task_id'=>$this->task,'authorized_by'=>'Synthetic client','capacity'=>'Synthetic owner','authorized_at'=>now()->toIso8601String(),'channel'=>'Synthetic record','scope'=>'This task only','evidence'=>'Synthetic authorisation']);
CarbonImmutable::setTestNow(CarbonImmutable::now()->addMinute());$this->entry=$this->material();
$this->bindingKey=$this->f['tenant'].':'.$this->f['company'].':'.$this->f['unit'];
$this->binding=['schema'=>$this->schema,'source_tenant_id'=>77,'eligible_order_ids'=>[$this->order],'lots'=>false,'reservations'=>false,'spare_parts'=>false,'resources'=>['CABO|UN'=>['stock_item_id'=>1,'stock_unit'=>'UN','warehouse_ids'=>[11]]]];
config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$this->binding,'stock_read.synthetic_sources.'.$this->bindingKey=>['schema'=>$this->schema,'source_tenant_id'=>77,'warehouse_ids'=>[11],'append_only'=>true]]);$this->login('manager');
}
protected function tearDown(): void
{
CarbonImmutable::setTestNow();
try{if(isset($this->constraint)){DB::transaction(function(){if(isset($this->f))DB::table('permission_grants')->whereIn('user_id',array_map(fn($n)=>$this->f[$n]->id,['manager','reader']))->whereIn('permission',['stock.view','workshop_stock.view','workshop_stock.prepare','workshop_stock.confirm'])->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->constraint);});}}finally{parent::tearDown();}
}
private function grant(string $name,string $r):void {IdentityFixture::grant($this->f[$name]->id,$this->f['tenant'],$this->f['company'],$r,$this->f['unit']);}
private function login(string $name,string $module='workshop'):void {$u=$this->f[$name];$this->actingAs($u)->withSession(['auth_version'=>$u->auth_version]);$token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit'],'module_id'=>$module])->assertOk()->json('context.token');$this->withHeader('X-SGF-Context',$token);}
private function revision():int {return (int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$this->order])->v;}
private function record(string $kind,array $extra):int {return $this->withHeader('Idempotency-Key',(string)Str::uuid())->postJson('/api/workshop/orders/'.$this->order.'/entries',array_replace(['kind'=>$kind,'origin'=>'QA-'.Str::uuid(),'version'=>1,'revision'=>$this->revision()],$extra))->assertOk()->json('id');}
private function material(string $quantity='2',string $owner='company',string $event='consumed',bool $verify=true):int
{
$this->login('tech');$id=$this->record('material',['task_id'=>$this->task,'authorization_id'=>$this->authorization,'starts_at'=>now()->subSeconds(10)->toIso8601String(),'resource'=>$owner==='company'?'CABO':'CLIENT-CABO','quantity'=>$quantity,'unit'=>'UN','owner'=>$owner,'event'=>$event,'evidence'=>'Synthetic material evidence']);
if($verify){$this->login('reviewer');$this->withHeader('Idempotency-Key',(string)Str::uuid())->postJson('/api/workshop/entries/'.$id.'/verification',['version'=>1,'revision'=>$this->revision(),'decision'=>'verified','reason'=>'Synthetic independent technical verification'])->assertOk();}return $id;
}
private function prepare(?int $id=null,?string $key=null,int $version=1){return $this->withHeader('Idempotency-Key',$key??(string)Str::uuid())->postJson('/api/workshop-stock/prepare',['entry_id'=>$id??$this->entry,'version'=>$version,'warehouse_id'=>11,'reason'=>'Synthetic material issue justification.','evidence'=>'Synthetic physical delivery proof']);}
private function confirm(int $id,bool $received=false){return $this->postJson('/api/workshop-stock/'.$id.'/confirm',['reason'=>'Synthetic independent stock confirmation.']+($received?['received_in_warehouse'=>true]:[]));}
private function balance():int{return (int)DB::table($this->schema.'.stock_movements')->sum('quantity_milli');}
private function closeProposal(int $id,string $action,array $extra=[]){return $this->postJson('/api/workshop-stock/'.$id.'/'.$action,$extra+['state_version'=>1,'source_hash'=>DB::table($this->schema.'.ws_proposals')->where('id',$id)->value('source_hash'),'reason'=>'Synthetic closure without any physical movement.']);}
public function test_closure_withdraws_stale_preview_and_replay_does_not_reopen_it():void
{
$key=(string)Str::uuid();$p=$this->prepare(key:$key)->assertOk()->assertJsonPath('state','pending')->json('id');$original=DB::table($this->schema.'.ws_proposals')->find($p);
DB::table($this->schema.'.stock_movements')->insert(['tenant_id'=>77,'stock_item_id'=>1,'warehouse_id'=>11,'quantity_milli'=>1000,'movement_type'=>'receipt_in','snapshot'=>'{}','moved_at'=>now()]);
$this->closeProposal($p,'withdraw')->assertOk()->assertJsonPath('state','withdrawn')->assertJsonPath('physical_effect_milli','0');$this->closeProposal($p,'withdraw')->assertOk()->assertJsonPath('replayed',true);
$this->prepare(key:$key)->assertOk()->assertJsonPath('id',$p)->assertJsonPath('state','withdrawn')->assertJsonPath('state_version',2)->assertJsonPath('replayed',true);
$this->closeProposal($p,'withdraw',['reason'=>'Another changed synthetic closure justification.'])->assertConflict();
$this->assertEquals($original,DB::table($this->schema.'.ws_proposals')->find($p));$this->assertSame(1,DB::table($this->schema.'.ws_closures')->count());$this->assertSame(11000,$this->balance());
$this->login('reader');$this->confirm($p)->assertConflict();$this->closeProposal($p,'reject')->assertConflict();$this->getJson('/api/workshop-stock?status=withdrawn')->assertOk()->assertJsonPath('proposals.0.state_version',2)->assertJsonPath('proposals.0.closure.before_state','pending')->assertJsonPath('proposal_pagination.total',1);
$this->login('manager');$new=$this->prepare()->assertOk()->json('id');$this->assertNotSame($p,$new);$this->login('reader');$this->confirm($new)->assertOk();$this->assertSame(9000,$this->balance());
$event=DB::table('identity_audit')->where(['company_id'=>$this->f['company'],'action'=>'workshop_stock.withdrawn'])->first();$this->assertNotNull($event);$details=json_decode($event->details,true);$this->assertSame($p,$details['record_id']);$this->assertSame(2,$details['version']);$this->assertArrayHasKey('decision_id',$details);
}
public function test_closure_requires_current_right_context_independence_hash_and_version():void
{
$p=$this->prepare()->assertOk()->json('id');$this->closeProposal($p,'reject')->assertForbidden();
$this->closeProposal($p,'withdraw',['state_version'=>2])->assertConflict();$this->closeProposal($p,'withdraw',['source_hash'=>str_repeat('0',64)])->assertConflict();$this->closeProposal($p,'withdraw',['source_hash'=>'invalid'])->assertStatus(422);$this->closeProposal($p,'withdraw',['tenant_id'=>999])->assertStatus(422);
$this->login('reader');$this->closeProposal($p,'withdraw')->assertForbidden();DB::table('permission_grants')->where(['user_id'=>$this->f['reader']->id,'permission'=>'workshop_stock.confirm'])->delete();$this->closeProposal($p,'reject')->assertForbidden();$this->grant('reader','workshop_stock.confirm');
$this->withHeader('X-SGF-Context','obsolete')->postJson('/api/workshop-stock/'.$p.'/reject',['state_version'=>1,'source_hash'=>DB::table($this->schema.'.ws_proposals')->value('source_hash'),'reason'=>'Synthetic stale context should be refused.'])->assertConflict();$this->login('reader');
$this->closeProposal($p,'reject')->assertOk()->assertJsonPath('state','rejected');$this->closeProposal($p,'reject')->assertOk()->assertJsonPath('replayed',true);$this->confirm($p)->assertConflict();$this->assertSame(10000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.stock_operations')->count());
}
public function test_closure_of_confirmed_proposal_requires_separate_physical_correction():void
{
$p=$this->prepare()->assertOk()->json('id');$this->login('reader');$movement=$this->confirm($p)->assertOk()->json('movement_id');$this->closeProposal($p,'reject')->assertConflict();$this->login('manager');$this->closeProposal($p,'withdraw')->assertConflict();$this->assertSame(0,DB::table($this->schema.'.ws_closures')->count());$this->assertSame(8000,$this->balance());$this->getJson('/api/workshop-stock?status=confirmed')->assertOk()->assertJsonPath('proposals.0.state','confirmed')->assertJsonPath('proposals.0.movement_id',$movement);
}
public function test_closure_failure_rolls_back_audit_and_recovers_with_one_decision():void
{
$p=$this->prepare()->assertOk()->json('id');$this->withoutExceptionHandling();$fault=true;DB::listen(function($q)use(&$fault){if($fault&&str_contains($q->sql,'insert into "identity_audit"')){$fault=false;throw new \RuntimeException('Synthetic closure audit failure');}});
try{$this->closeProposal($p,'withdraw');$this->fail('Fault expected');}catch(\RuntimeException $e){$this->assertSame('Synthetic closure audit failure',$e->getMessage());}
$this->assertSame(0,DB::table($this->schema.'.ws_closures')->count());$this->assertSame(0,DB::table('identity_audit')->where(['company_id'=>$this->f['company'],'action'=>'workshop_stock.withdrawn'])->count());$this->withExceptionHandling();$this->closeProposal($p,'withdraw')->assertOk();$this->closeProposal($p,'withdraw')->assertOk()->assertJsonPath('replayed',true);$this->assertSame(1,DB::table($this->schema.'.ws_closures')->count());$this->assertSame(10000,$this->balance());
try{DB::transaction(fn()=>DB::table($this->schema.'.ws_closures')->where('proposal_id',$p)->update(['state'=>'rejected']));$this->fail('Append-only expected');}catch(\Illuminate\Database\QueryException $e){$this->assertSame('P0001',$e->errorInfo[0]);}
}
public function test_closure_history_filters_and_pages_use_only_the_current_page():void
{
$p=$this->prepare()->assertOk()->json('id');$row=(array)DB::table($this->schema.'.ws_proposals')->find($p);unset($row['id']);for($i=0;$i<50;$i++){$row['command_key']=(string)Str::uuid();DB::table($this->schema.'.ws_proposals')->insert($row);}
$ids=DB::table($this->schema.'.ws_proposals')->orderBy('id')->pluck('id');foreach($ids->take(2) as $id)$this->closeProposal((int)$id,'withdraw')->assertOk();$this->login('reader');$this->closeProposal((int)$ids[2],'reject')->assertOk();
$queries=[];$capture=true;DB::listen(function($q)use(&$capture,&$queries){if($capture&&str_starts_with(strtolower($q->sql),'select')&&str_contains($q->sql,'ws_closures'))$queries[]=$q->sql;});
$first=$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('closure_supported',true)->assertJsonPath('proposal_pagination.total',51)->assertJsonCount(25,'proposals')->json('proposals');$capture=false;
$this->assertCount(2,$queries);$this->assertCount(1,array_filter($queries,fn($sql)=>str_contains($sql,'from "'.$this->schema.'"."ws_closures"')));$this->assertCount(1,array_filter($queries,fn($sql)=>str_contains($sql,'from pg_class')));
$second=$this->getJson('/api/workshop-stock?proposal_page=2')->assertOk()->assertJsonCount(25,'proposals')->json('proposals');$this->assertEmpty(array_intersect(array_column($first,'id'),array_column($second,'id')));
$this->getJson('/api/workshop-stock?status=pending')->assertOk()->assertJsonPath('proposal_pagination.total',48);$this->getJson('/api/workshop-stock?status=withdrawn')->assertOk()->assertJsonPath('proposal_pagination.total',2);$this->getJson('/api/workshop-stock?status=rejected')->assertOk()->assertJsonPath('proposal_pagination.total',1);$this->getJson('/api/workshop-stock?status=confirmed')->assertOk()->assertJsonPath('proposal_pagination.total',0);$this->assertSame(10000,$this->balance());
file_put_contents(base_path('../private/workshop-closure-history.json'),json_encode(['fixture_proposals'=>51,'closed'=>3,'pending'=>48,'page_size'=>25,'closure_catalog_discovery_selects'=>1,'closure_batch_data_selects'=>1,'pages_disjoint'=>true,'not_a_capacity_benchmark'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
}
public function test_closure_cannot_cross_company_or_unit_even_with_shared_source_binding():void
{
$p=$this->prepare()->assertOk()->json('id');$tenant=$this->f['tenant'];$company=$this->f['sibling'];$unit=$this->f['siblingUnit'];
foreach(['workshop.view','stock.view','workshop_stock.view','workshop_stock.prepare','workshop_stock.confirm'] as $r)IdentityFixture::grant($this->f['manager']->id,$tenant,$company,$r,$unit);
config(['modules.companies.'.$tenant.':'.$company=>['contracted'=>['workshop','stock'],'enabled'=>['workshop','stock']],'workshop_stock.synthetic_sources.'.$tenant.':'.$company.':'.$unit=>$this->binding]);
$token=$this->postJson('/api/context',['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit,'module_id'=>'workshop'])->assertOk()->json('context.token');$this->withHeader('X-SGF-Context',$token);
$this->closeProposal($p,'withdraw')->assertForbidden();$this->closeProposal($p,'reject')->assertForbidden();$this->getJson('/api/workshop-stock?status=withdrawn')->assertOk()->assertJsonCount(0,'proposals');$this->assertSame(0,DB::table($this->schema.'.ws_closures')->count());$this->assertSame(10000,$this->balance());
}
public function test_closure_absent_in_legacy_synthetic_source_does_not_disable_confirmation():void
{
// Only this test's new empty table is removed; no retained fixture or installed schema is changed.
$this->assertSame(0,DB::table($this->schema.'.ws_closures')->count());DB::statement('DROP TABLE '.$this->schema.'.ws_closures');
$p=$this->prepare()->assertOk()->json('id');$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('closure_supported',false);$this->closeProposal($p,'withdraw')->assertStatus(503);$this->getJson('/api/workshop-stock?status=withdrawn')->assertOk()->assertJsonPath('proposal_pagination.total',0);
$this->login('reader');$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());$this->assertSame(1,DB::table($this->schema.'.ws_claims')->count());
}
public function test_closure_for_delivery_allocation_and_return_never_moves_or_claims_material():void
{
$movement=$this->previousDelivery(5000);$this->enableReturns();$p=$this->prepareAllocation($movement)->assertOk()->json('id');$this->closeProposal($p,'withdraw')->assertOk();
$entry=$this->material('3','company','returned');$this->login('manager');$key=(string)Str::uuid();$p=$this->prepareReturn($movement,$entry,$key)->assertOk()->json('id');$this->login('reader');$this->closeProposal($p,'reject')->assertOk();$this->confirm($p,true)->assertConflict();
$this->login('manager');$this->prepareReturn($movement,$entry,$key)->assertOk()->assertJsonPath('state','rejected')->assertJsonPath('replayed',true);$p=$this->prepareReturn($movement,$entry)->assertOk()->json('id');$this->closeProposal($p,'withdraw')->assertOk();
$this->assertSame(5000,$this->balance());$this->assertSame(2,DB::table($this->schema.'.stock_movements')->count());foreach(['ws_claims','ws_allocations','ws_returns'] as $table)$this->assertSame(0,DB::table($this->schema.'.'.$table)->count());
}
public function test_closure_withdrawal_wins_over_concurrent_confirmation():void {$this->closureRace('withdrawn','confirm',409);}
public function test_closure_confirmation_wins_over_concurrent_withdrawal():void {$this->closureRace('confirm','withdrawn',409);}
public function test_closure_concurrent_identical_withdrawals_replay_one_decision():void {$this->closureRace('withdrawn','withdrawn',200);}
private function closureRace(string $first,string $second,int $expected):void
{
$p=$this->prepare()->assertOk()->json('id');$scope=['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']];$hash=DB::table($this->schema.'.ws_proposals')->where('id',$p)->value('source_hash');
$actor=$second==='confirm'?'reader':'manager';$dir=base_path('../private/workshop-closure-'.bin2hex(random_bytes(6)));mkdir($dir,0700,true);$input=$dir.'/worker.json';$application='ws_closure_'.bin2hex(random_bytes(6));
file_put_contents($input,json_encode(['actor'=>$this->f[$actor]->id,'scope'=>$scope,'binding'=>$this->binding,'proposal'=>$p,'application'=>$application,'action'=>$second,'state_version'=>1,'source_hash'=>$hash,'reason'=>'Synthetic closure without any physical movement.'],JSON_THROW_ON_ERROR));
$this->login($first==='confirm'?'reader':'manager');$process=null;DB::beginTransaction();
try{
($first==='confirm'?$this->confirm($p):$this->closeProposal($p,'withdraw'))->assertOk();$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/workshop-stock-worker.php'),$input],[0=>['pipe','r'],1=>['file',$dir.'/stdout','w'],2=>['file',$dir.'/stderr','w']],$pipes,base_path());$this->assertIsResource($process);fclose($pipes[0]);
$parent=(int)DB::selectOne('SELECT pg_backend_pid() AS id')->id;$wait=null;$deadline=microtime(true)+10;do{DB::select('SELECT pg_stat_clear_snapshot()');$wait=DB::selectOne("SELECT to_json(pg_blocking_pids(pid)) AS blockers FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'",[$application]);if(!$wait)usleep(25000);}while(!$wait&&microtime(true)<$deadline);$this->assertNotNull($wait);$this->assertContains($parent,json_decode($wait->blockers,true));DB::commit();
$deadline=microtime(true)+15;do{$status=proc_get_status($process);if(!$status['running'])break;usleep(25000);}while(microtime(true)<$deadline);$this->assertFalse($status['running']);$exit=proc_close($process);$process=null;if($exit===-1)$exit=$status['exitcode'];$this->assertSame(0,$exit);$result=json_decode(file_get_contents($input.'.result'),true);$this->assertSame($expected,$result['status']);if($expected===200)$this->assertTrue($result['result']['replayed']);
}finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();}
$this->assertSame($first==='confirm'?1:0,DB::table($this->schema.'.ws_claims')->count());$this->assertSame($first==='confirm'?0:1,DB::table($this->schema.'.ws_closures')->count());$this->assertSame($first==='confirm'?8000:10000,$this->balance());
}
private function previousDelivery(int $quantity=2000):int
{
require_once base_path('packages/inventory-issue/bootstrap.php');
$movement=DB::transaction(function()use($quantity){
$book=DB::table($this->schema.'.stock_movements')->selectRaw('sum(quantity_milli) AS quantity,max(id) AS revision')->first();
$id=DB::table($this->schema.'.stock_operations')->insertGetId(['tenant_id'=>77,'created_by'=>$this->f['manager']->id,'warehouse_id'=>11,'stock_item_id'=>1,'kind'=>'issue','reference'=>'QA-DELIVERY-'.Str::uuid(),'idempotency_key'=>(string)Str::uuid(),'quantity_milli'=>$quantity,'ledger_version'=>$book->revision,'theoretical_milli'=>$book->quantity,'reason'=>'Synthetic previous delivery','recorded_at'=>now()->subMinutes(2),'snapshot'=>'{}']);
$w=new \Tempo\Inventory\IssueMovementWriter(DB::connection(),$this->schema,77,$this->f['manager']->id,fn()=>null);
// Clock is set before the consumed observation: the delivered movement is immutable.
$clock=CarbonImmutable::now();CarbonImmutable::setTestNow($clock->subMinutes(2));
try{return $w->write($id,$w->preview($id))['movement_id'];}finally{CarbonImmutable::setTestNow($clock);}
});
$this->binding['eligible_deliveries'][$movement]=['order_id'=>$this->order];config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$this->binding]);return $movement;
}
private function prepareDelivery(int $movement,?string $key=null,?int $entry=null)
{
return $this->withHeader('Idempotency-Key',$key??(string)Str::uuid())->postJson('/api/workshop-stock/prepare-delivery',['entry_id'=>$entry??$this->entry,'version'=>1,'warehouse_id'=>11,'movement_id'=>$movement,'reason'=>'Synthetic existing delivery reconciliation.','evidence'=>'Synthetic delivery document and consumed origin']);
}
private function prepareAllocation(int $movement,?int $entry=null,?string $key=null)
{
return $this->withHeader('Idempotency-Key',$key??(string)Str::uuid())->postJson('/api/workshop-stock/prepare-allocation',['entry_id'=>$entry??$this->entry,'version'=>1,'warehouse_id'=>11,'movement_id'=>$movement,'reason'=>'Synthetic individual delivery allocation.','evidence'=>'Synthetic consumed origin and delivery evidence']);
}
private function enableAllocations():void {$this->binding['partial_delivery_links']=true;config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$this->binding]);}
private function enableReturns():void {$this->binding['stock_returns']=true;$this->enableAllocations();}
private function prepareReturn(int $movement,int $entry,?string $key=null)
{
return $this->withHeader('Idempotency-Key',$key??(string)Str::uuid())->postJson('/api/workshop-stock/prepare-return',['entry_id'=>$entry,'version'=>1,'warehouse_id'=>11,'movement_id'=>$movement,'reason'=>'Synthetic warehouse return receiving confirmation.','evidence'=>'Synthetic physical warehouse receipt evidence']);
}
public function test_stock_return_unconsumed_material_restores_only_remaining_quantity_once():void
{
$opening=DB::table($this->schema.'.stock_movements')->where('movement_type','receipt_in')->value('moved_at');$this->assertTrue(CarbonImmutable::parse($opening)->lessThan(CarbonImmutable::now()->subMinutes(2)));
$movement=$this->previousDelivery(5000);$this->enableReturns();$original=DB::table($this->schema.'.stock_movements')->find($movement);$p=$this->prepareAllocation($movement)->assertOk()->json('id');$this->login('reader');$this->confirm($p,true)->assertOk();
$returned=$this->material('3','company','returned');$this->login('manager');$key=(string)Str::uuid();$p=$this->prepareReturn($movement,$returned,$key)->assertOk()->assertJsonPath('impact.mode','return_existing_delivery')->assertJsonPath('impact.physical_before','5000')->assertJsonPath('impact.physical_after','8000')->assertJsonPath('impact.unallocated_after','0')->json('id');
$this->prepareReturn($movement,$returned,$key)->assertOk()->assertJsonPath('id',$p)->assertJsonPath('replayed',true);$this->confirm($p,true)->assertForbidden();$this->login('reader');$this->confirm($p)->assertStatus(422);$this->assertSame(5000,$this->balance());$returnMovement=$this->confirm($p,true)->assertOk()->json('movement_id');$this->confirm($p,true)->assertOk()->assertJsonPath('movement_id',$returnMovement)->assertJsonPath('replayed',true);
$this->assertSame(8000,$this->balance());$this->assertSame(3,DB::table($this->schema.'.stock_movements')->count());$this->assertSame(1,DB::table($this->schema.'.ws_returns')->count());$this->assertEquals($original,DB::table($this->schema.'.stock_movements')->find($movement));
$returnOp=(int)DB::table($this->schema.'.ws_returns')->value('operation_id');DB::transaction(function()use($returnOp,$returnMovement){$writer=new \Tempo\Inventory\DeliveryReturnWriter(DB::connection(),$this->schema,77,$this->f['reader']->id,fn()=>null);$r=$writer->write($returnOp,$writer->preview($returnOp));$this->assertTrue($r['replayed']);$this->assertSame($returnMovement,$r['movement_id']);});
$this->getJson('/api/workshop-stock?status=confirmed')->assertOk()->assertJsonPath('proposal_pagination.total',2)->assertJsonPath('proposals.0.movement_id',$returnMovement);
$this->login('reader','stock');$this->getJson('/api/stock-read')->assertOk()->assertJsonPath('rows.0.physical_milli','8000');$this->login('manager');$this->prepareReturn($movement,$returned)->assertConflict();
$extra=$this->material('1');$this->login('manager');$this->prepareAllocation($movement,$extra)->assertConflict();$this->prepare($extra)->assertOk();
}
public function test_stock_return_refuses_consumed_client_unverified_excess_and_stale_capacity():void
{
$movement=$this->previousDelivery(5000);$this->enableReturns();$this->prepareReturn($movement,$this->entry)->assertStatus(422);
foreach([[$this->material('1','client','returned'),422],[$this->material('1','company','returned',false),409],[$this->material('0.0001','company','returned'),422],[$this->material('6','company','returned'),409]] as [$entry,$status]){$this->login('manager');$this->prepareReturn($movement,$entry)->assertStatus($status);}
$return=$this->material('4','company','returned');$this->login('manager');$p=$this->prepareReturn($movement,$return)->assertOk()->json('id');$allocation=$this->prepareAllocation($movement)->assertOk()->json('id');$this->login('reader');$this->confirm($allocation)->assertOk();$this->confirm($p,true)->assertConflict();$this->assertSame(5000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.ws_returns')->count());
}
public function test_stock_return_failure_after_decision_rolls_back_movement_and_recovers():void
{
$movement=$this->previousDelivery(5000);$this->enableReturns();$returned=$this->material('1','company','returned');$this->login('manager');$p=$this->prepareReturn($movement,$returned)->assertOk()->json('id');$this->login('reader');
$fault=true;DB::listen(function($q)use(&$fault){if($fault&&preg_match('/insert into .*ws_returns/i',$q->sql)){$fault=false;throw new \RuntimeException('Synthetic return decision failure');}});
$this->withoutExceptionHandling();try{$this->confirm($p,true);$this->fail('Injected failure absent');}catch(\RuntimeException $e){$this->assertSame('Synthetic return decision failure',$e->getMessage());}$this->withExceptionHandling();
$this->assertSame(5000,$this->balance());$this->assertSame(2,DB::table($this->schema.'.stock_movements')->count());$this->assertSame(1,DB::table($this->schema.'.stock_operations')->count());$this->assertSame(0,DB::table($this->schema.'.ws_returns')->count());$this->confirm($p,true)->assertOk();$this->assertSame(6000,$this->balance());$this->assertSame(1,DB::table($this->schema.'.ws_returns')->count());
}
public function test_stock_return_concurrency_cannot_restore_quantity_twice():void
{
$movement=$this->previousDelivery(5000);$this->enableReturns();$one=$this->material('3','company','returned');$two=$this->material('3','company','returned');$this->login('manager');$p=$this->prepareReturn($movement,$one)->assertOk()->json('id');$other=$this->prepareReturn($movement,$two)->assertOk()->json('id');
$this->concurrentConfirmation(false,$p,$other,409,true);$this->assertSame(8000,$this->balance());$this->assertSame(1,DB::table($this->schema.'.ws_returns')->count());$this->assertSame(3,DB::table($this->schema.'.stock_movements')->count());$this->assertSame(3000,(int)DB::table($this->schema.'.ws_returns')->sum('quantity_milli'));
}
public function test_stock_return_revoked_right_stale_context_and_foreign_company_do_not_write():void
{
$movement=$this->previousDelivery(5000);$this->enableReturns();$entry=$this->material('1','company','returned');$this->login('manager');$p=$this->prepareReturn($movement,$entry)->assertOk()->json('id');$this->login('reader');
DB::table('permission_grants')->where(['user_id'=>$this->f['reader']->id,'permission'=>'workshop_stock.confirm'])->delete();$this->confirm($p,true)->assertForbidden();$this->grant('reader','workshop_stock.confirm');$this->withHeader('X-SGF-Context','obsolete')->postJson('/api/workshop-stock/'.$p.'/confirm',['reason'=>'Synthetic warehouse receiving','received_in_warehouse'=>true])->assertConflict();$this->login('manager');
$tenant=$this->f['tenant'];$company=$this->f['sibling'];$unit=$this->f['siblingUnit'];foreach(['workshop.view','stock.view','workshop_stock.view','workshop_stock.prepare','workshop_stock.confirm'] as $r)IdentityFixture::grant($this->f['manager']->id,$tenant,$company,$r,$unit);
config(['modules.companies.'.$tenant.':'.$company=>['contracted'=>['workshop','stock'],'enabled'=>['workshop','stock']],'workshop_stock.synthetic_sources.'.$tenant.':'.$company.':'.$unit=>$this->binding]);$token=$this->postJson('/api/context',['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit,'module_id'=>'workshop'])->assertOk()->json('context.token');$this->withHeader('X-SGF-Context',$token);
$this->prepareReturn($movement,$entry)->assertForbidden();$this->prepareAllocation($movement)->assertForbidden();$this->confirm($p,true)->assertForbidden();$this->getJson('/api/workshop-stock')->assertOk()->assertJsonCount(0,'proposals')->assertJsonPath('pagination.total',0);$this->assertSame(5000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.ws_returns')->count());
}
public function test_partial_delivery_links_multiple_origins_without_debit_and_rechecks_remaining():void
{
$movement=$this->previousDelivery(5000);$this->enableAllocations();$other=$this->material('3');$this->login('manager');$key=(string)Str::uuid();
$p=$this->prepareAllocation($movement,null,$key)->assertOk()->assertJsonPath('impact.mode','allocate_existing_delivery')->assertJsonPath('impact.unallocated_before','5000')->assertJsonPath('impact.unallocated_after','3000')->assertJsonPath('impact.physical_after','5000')->json('id');
$this->prepareAllocation($movement,null,$key)->assertOk()->assertJsonPath('id',$p)->assertJsonPath('replayed',true);$stale=$this->prepareAllocation($movement,$other)->assertOk()->json('id');$this->confirm($p)->assertForbidden();
$this->login('reader');$this->confirm($p)->assertOk();$this->confirm($p)->assertOk()->assertJsonPath('replayed',true);$this->confirm($stale)->assertConflict();
$this->login('manager');$this->prepare()->assertConflict();$p=$this->prepareAllocation($movement,$other)->assertOk()->assertJsonPath('impact.unallocated_before','3000')->assertJsonPath('impact.unallocated_after','0')->json('id');$this->login('reader');$this->confirm($p)->assertOk();
$this->assertSame(5000,$this->balance());$this->assertSame(5000,(int)DB::table($this->schema.'.ws_allocations')->sum('quantity_milli'));$this->assertSame(2,DB::table($this->schema.'.ws_allocations')->count());$this->assertSame(2,DB::table($this->schema.'.stock_movements')->count());
$this->login('manager');$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('sources.0.movement_id',$movement)->assertJsonPath('proposals.0.movement_id',$movement);
$new=$this->material('1');$this->login('manager');$this->prepareAllocation($movement,$new)->assertConflict();$this->prepare($new)->assertOk();
// Disabling new allocations must not erase protection for existing allocated origins.
unset($this->binding['partial_delivery_links']);config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$this->binding]);$this->prepare()->assertConflict();
}
public function test_partial_delivery_requires_enabled_capability_and_rolls_back_allocation_failure():void
{
$movement=$this->previousDelivery(5000);$this->prepareAllocation($movement)->assertStatus(503);$this->enableAllocations();$p=$this->prepareAllocation($movement)->assertOk()->json('id');$this->login('reader');
$fault=true;DB::listen(function($q)use(&$fault){if($fault&&preg_match('/insert into .*ws_allocations/i',$q->sql)){$fault=false;throw new \RuntimeException('Synthetic allocation failure');}});
$this->withoutExceptionHandling();try{$this->confirm($p);$this->fail('Injected failure absent');}catch(\RuntimeException $e){$this->assertSame('Synthetic allocation failure',$e->getMessage());}$this->withExceptionHandling();
$this->assertSame(0,DB::table($this->schema.'.ws_allocations')->count());$this->assertSame(5000,$this->balance());$this->confirm($p)->assertOk();$this->assertSame(1,DB::table($this->schema.'.ws_allocations')->count());$this->assertSame(2,DB::table($this->schema.'.stock_movements')->count());
}
public function test_partial_delivery_concurrent_proposals_cannot_overallocate():void
{
$one=$this->material('3');$two=$this->material('3');$this->login('manager');$movement=$this->previousDelivery(5000);$this->enableAllocations();$p=$this->prepareAllocation($movement,$one)->assertOk()->json('id');$other=$this->prepareAllocation($movement,$two)->assertOk()->json('id');
$this->concurrentConfirmation(false,$p,$other,409);$this->assertSame(5000,$this->balance());$this->assertSame(3000,(int)DB::table($this->schema.'.ws_allocations')->sum('quantity_milli'));$this->assertSame(1,DB::table($this->schema.'.ws_allocations')->count());
$this->login('manager');$this->prepareAllocation($movement,$two)->assertConflict();
}
public function test_history_filters_and_pagination_preserve_order_and_scope():void
{
$p=$this->prepare()->assertOk()->json('id');$original=(array)DB::table($this->schema.'.ws_proposals')->find($p);unset($original['id']);
for($i=0;$i<50;$i++){$row=$original;$row['command_key']=(string)Str::uuid();DB::table($this->schema.'.ws_proposals')->insert($row);}
$first=$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('proposal_pagination.total',51)->assertJsonPath('proposal_pagination.last_page',3)->assertJsonCount(25,'proposals')->json('proposals');
$second=$this->getJson('/api/workshop-stock?proposal_page=2')->assertOk()->assertJsonCount(25,'proposals')->json('proposals');$this->assertEmpty(array_intersect(array_column($first,'id'),array_column($second,'id')));$this->assertGreaterThan($second[0]['id'],$first[24]['id']);
$this->login('reader');$this->confirm($p)->assertOk();$this->getJson('/api/workshop-stock?status=confirmed')->assertOk()->assertJsonPath('proposal_pagination.total',1)->assertJsonPath('proposals.0.id',$p);$this->getJson('/api/workshop-stock?status=pending&proposal_page=2')->assertOk()->assertJsonPath('proposal_pagination.total',50)->assertJsonCount(25,'proposals');
$this->getJson('/api/workshop-stock?status=unknown')->assertStatus(422);$this->getJson('/api/workshop-stock?proposal_page=0')->assertStatus(422);$this->getJson('/api/workshop-stock?tenant_id=999')->assertStatus(422);$this->assertSame(8000,$this->balance());
}
public function test_delivery_link_has_zero_effect_independent_decision_and_no_duplicate():void
{
$movement=$this->previousDelivery();$frozen=DB::table($this->schema.'.stock_movements')->find($movement);$this->prepare()->assertConflict();$key=(string)Str::uuid();
$p=$this->prepareDelivery($movement,$key)->assertOk()->assertJsonPath('impact.mode','link_existing_delivery')->assertJsonPath('impact.physical_before','8000')->assertJsonPath('impact.physical_after','8000')->assertJsonPath('impact.quantity_milli','0')->assertJsonPath('impact.linked_quantity_milli','2000')->json('id');
$other=$this->prepareDelivery($movement)->assertOk()->json('id');$this->prepareDelivery($movement,$key)->assertOk()->assertJsonPath('id',$p)->assertJsonPath('replayed',true);$this->confirm($p)->assertForbidden();
$this->login('reader');$this->confirm($p)->assertOk()->assertJsonPath('movement_id',$movement);$this->confirm($p)->assertOk()->assertJsonPath('replayed',true);$this->confirm($other)->assertConflict();
$this->assertSame(8000,$this->balance());$this->assertSame(2,DB::table($this->schema.'.stock_movements')->count());$this->assertSame(1,DB::table($this->schema.'.stock_operations')->count());$this->assertSame(1,DB::table($this->schema.'.ws_claims')->count());
$this->assertEquals($frozen,DB::table($this->schema.'.stock_movements')->find($movement));
$claim=DB::table($this->schema.'.ws_claims')->first();$this->assertSame($claim->before_milli,$claim->after_milli);
$this->login('reader','stock');$this->getJson('/api/stock-read')->assertOk()->assertJsonPath('rows.0.physical_milli','8000');
}
public function test_delivery_link_refuses_mismatch_stale_book_and_recovers_from_failure():void
{
$movement=$this->previousDelivery();$this->prepareDelivery(99999999)->assertForbidden();
$different=$this->material('3');$this->login('manager');$this->prepareDelivery($movement,null,$different)->assertConflict();
$p=$this->prepareDelivery($movement)->assertOk()->json('id');DB::table($this->schema.'.stock_movements')->insert(['tenant_id'=>77,'stock_item_id'=>1,'warehouse_id'=>11,'movement_type'=>'receipt_in','quantity_milli'=>1000,'snapshot'=>'{}','moved_at'=>now()]);$this->login('reader');$this->confirm($p)->assertConflict();
$this->login('manager');$p=$this->prepareDelivery($movement)->assertOk()->json('id');$this->login('reader');
$b=$this->binding;unset($b['eligible_deliveries']);config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$b]);$this->confirm($p)->assertForbidden();config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$this->binding]);
DB::table('permission_grants')->where(['user_id'=>$this->f['reader']->id,'permission'=>'workshop_stock.confirm'])->delete();$this->confirm($p)->assertForbidden();$this->grant('reader','workshop_stock.confirm');
$fault=true;DB::listen(function($q)use(&$fault){if($fault&&preg_match('/insert into .*ws_claims/i',$q->sql)){$fault=false;throw new \RuntimeException('Synthetic delivery link failure');}});
$this->withoutExceptionHandling();try{$this->confirm($p);$this->fail('Injected failure absent');}catch(\RuntimeException $e){$this->assertSame('Synthetic delivery link failure',$e->getMessage());}$this->withExceptionHandling();
$this->assertSame(0,DB::table($this->schema.'.ws_claims')->count());$this->assertSame(9000,$this->balance());$this->confirm($p)->assertOk()->assertJsonPath('movement_id',$movement);$this->assertSame(9000,$this->balance());
$this->assertSame(1,DB::table($this->schema.'.stock_operations')->count());
}
public function test_delivery_link_can_reconcile_fully_depleted_stock_without_new_issue():void
{
$entry=$this->material('10');$this->login('manager');$movement=$this->previousDelivery(10000);$this->assertSame(0,$this->balance());
$p=$this->prepareDelivery($movement,null,$entry)->assertOk()->assertJsonPath('impact.physical_before','0')->assertJsonPath('impact.physical_after','0')->json('id');$this->login('reader');$this->confirm($p)->assertOk();$this->assertSame(0,$this->balance());$this->assertSame(2,DB::table($this->schema.'.stock_movements')->count());
}
public function test_delivery_link_rejects_reversal_and_does_not_adopt_history():void
{
$movement=$this->previousDelivery();
DB::table($this->schema.'.stock_movements')->insert(['tenant_id'=>77,'stock_item_id'=>1,'warehouse_id'=>11,'quantity_milli'=>2000,'movement_type'=>'reversal_in','snapshot'=>'{}','moved_at'=>now()]);
$this->prepareDelivery($movement)->assertConflict();$this->assertSame(10000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.ws_claims')->count());
}
public function test_delivery_link_refuses_missing_original_hash():void
{
$original=$this->previousDelivery();$m=(array)DB::table($this->schema.'.stock_movements')->find($original);$o=(array)DB::table($this->schema.'.stock_operations')->find($m['stock_operation_id']);unset($o['id'],$m['id']);
$o['reference']='QA-UNTRUSTED-'.Str::uuid();$o['idempotency_key']=(string)Str::uuid();$m['stock_operation_id']=DB::table($this->schema.'.stock_operations')->insertGetId($o);$m['snapshot']='{}';$movement=DB::table($this->schema.'.stock_movements')->insertGetId($m);
$this->binding['eligible_deliveries'][$movement]=['order_id'=>$this->order];config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$this->binding]);
$this->prepareDelivery($movement)->assertConflict()->assertJsonPath('message','Entrega sem fotografia íntegra do escritor comum; exige conferência histórica própria.');$this->assertSame(6000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.ws_claims')->count());
}
public function test_delivery_link_other_company_cannot_prepare_or_confirm():void
{
$movement=$this->previousDelivery();$p=$this->prepareDelivery($movement)->assertOk()->json('id');$tenant=$this->f['tenant'];$company=$this->f['sibling'];$unit=$this->f['siblingUnit'];
foreach(['workshop.view','stock.view','workshop_stock.view','workshop_stock.prepare','workshop_stock.confirm'] as $r)IdentityFixture::grant($this->f['manager']->id,$tenant,$company,$r,$unit);
config(['modules.companies.'.$tenant.':'.$company=>['contracted'=>['workshop','stock'],'enabled'=>['workshop','stock']],'workshop_stock.synthetic_sources.'.$tenant.':'.$company.':'.$unit=>$this->binding]);
$token=$this->postJson('/api/context',['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit,'module_id'=>'workshop'])->assertOk()->json('context.token');$this->withHeader('X-SGF-Context',$token);
$this->prepareDelivery($movement)->assertForbidden();$this->confirm($p)->assertForbidden();$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('pagination.total',0)->assertJsonCount(0,'proposals');$this->assertSame(8000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.ws_claims')->count());
}
public function test_individual_impact_independence_exact_issue_and_replay():void
{
$r=$this->getJson('/api/workshop-stock');$this->assertSame(200,$r->status(),$r->getContent());$r->assertJsonPath('pagination.total',1);$key=(string)Str::uuid();
$p=$this->prepare(null,$key)->assertOk()->assertJsonPath('impact.physical_before','10000')->assertJsonPath('impact.physical_after','8000')->json('id');
$this->prepare(null,$key)->assertOk()->assertJsonPath('id',$p)->assertJsonPath('replayed',true);$this->assertSame(10000,$this->balance());
$this->confirm($p)->assertForbidden();$this->login('reader');$first=$this->confirm($p)->assertOk()->assertJsonPath('replayed',false)->json('movement_id');
$this->confirm($p)->assertOk()->assertJsonPath('movement_id',$first)->assertJsonPath('replayed',true);$this->assertSame(8000,$this->balance());
$this->login('reader','stock');$this->getJson('/api/stock-read')->assertOk()->assertJsonPath('rows.0.physical_milli','8000');
$this->assertSame(1,DB::table($this->schema.'.ws_claims')->count());$this->assertSame(1,DB::table($this->schema.'.stock_operations')->count());
$this->login('manager');$this->prepare()->assertConflict();
$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('proposals.0.movement_id',$first);
$this->login('tech');$this->getJson('/api/workshop-stock')->assertForbidden();
}
public function test_client_unverified_fraction_and_unknown_delivery_do_not_debit():void
{
foreach([[$this->material('2','client'),422],[$this->material('2','company','delivered'),422],[$this->material('2','company','consumed',false),409],[$this->material('0.0001'),422],[$this->material('20'),409]] as [$id,$status]){$this->login('manager');$this->prepare($id)->assertStatus($status);}
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->prepare()->assertConflict();$this->assertSame(9000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.ws_claims')->count());
}
public function test_stale_book_mapping_permission_scope_and_failure_recovery():void
{
$p=$this->prepare()->assertOk()->json('id');DB::table($this->schema.'.stock_movements')->insert(['tenant_id'=>77,'stock_item_id'=>1,'warehouse_id'=>11,'quantity_milli'=>1000,'movement_type'=>'receipt_in','snapshot'=>'{}','moved_at'=>now()]);$this->login('reader');$this->confirm($p)->assertConflict();
$this->login('manager');$p=$this->prepare()->assertOk()->json('id');$this->login('reader');$b=$this->binding;$b['resources']['CABO|UN']['stock_unit']='KG';config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$b]);$this->confirm($p)->assertConflict();config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$this->binding]);
DB::table('permission_grants')->where(['user_id'=>$this->f['reader']->id,'permission'=>'workshop_stock.confirm'])->delete();$this->confirm($p)->assertForbidden();$this->grant('reader','workshop_stock.confirm');
$fault=true;DB::listen(function($q)use(&$fault){if($fault&&preg_match('/insert into .*ws_claims/i',$q->sql)){$fault=false;throw new \RuntimeException('Synthetic claim failure');}});
$this->withoutExceptionHandling();try{$this->confirm($p);$this->fail('Injected failure absent');}catch(\RuntimeException $e){$this->assertSame('Synthetic claim failure',$e->getMessage());}$this->withExceptionHandling();
$this->assertSame(11000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.stock_operations')->count());$this->confirm($p)->assertOk();$this->assertSame(9000,$this->balance());
$this->withHeader('X-SGF-Context','obsolete')->getJson('/api/workshop-stock')->assertConflict();
}
public function test_corrected_source_requires_review_and_never_consumes_the_same_origin_twice():void
{
$p=$this->prepare()->assertOk()->json('id');$old=DB::table('wo_entries')->find($this->entry);$this->login('tech');
$extra=['origin'=>$old->origin,'previous_id'=>$old->id,'version'=>2,'correction_reason'=>'Synthetic quantity correction','task_id'=>$this->task,'authorization_id'=>$this->authorization,'starts_at'=>CarbonImmutable::parse($old->starts_at)->utc()->toIso8601String(),'resource'=>'CABO','quantity'=>'3','unit'=>'UN','owner'=>'company','event'=>'consumed','evidence'=>'Synthetic corrected material evidence'];
$new=$this->record('material',$extra);$this->login('reader');$this->confirm($p)->assertConflict();
$this->login('reviewer');$this->withHeader('Idempotency-Key',(string)Str::uuid())->postJson('/api/workshop/entries/'.$new.'/verification',['version'=>2,'revision'=>$this->revision(),'decision'=>'verified','reason'=>'Synthetic corrected version verification'])->assertOk();
$this->login('manager');$p=$this->prepare($new,null,2)->assertOk()->json('id');$this->login('reader');$this->confirm($p)->assertOk();$this->assertSame(7000,$this->balance());
$this->login('tech');$extra['previous_id']=$new;$extra['version']=3;$extra['quantity']='4';$latest=$this->record('material',$extra);
$this->login('reviewer');$this->withHeader('Idempotency-Key',(string)Str::uuid())->postJson('/api/workshop/entries/'.$latest.'/verification',['version'=>3,'revision'=>$this->revision(),'decision'=>'verified','reason'=>'Synthetic later corrected verification'])->assertOk();
$this->login('manager');$this->prepare($latest,null,3)->assertConflict();$this->assertSame(7000,$this->balance());
$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('sources.0.id',$latest);$this->assertSame(1,DB::table($this->schema.'.ws_claims')->count());
}
public function test_other_company_cannot_read_or_confirm_origin_and_changed_command_is_refused():void
{
$key=(string)Str::uuid();$p=$this->prepare(null,$key)->assertOk()->json('id');
$this->withHeader('Idempotency-Key',$key)->postJson('/api/workshop-stock/prepare',['entry_id'=>$this->entry,'version'=>1,'warehouse_id'=>11,'reason'=>'Changed synthetic justification.','evidence'=>'Synthetic physical delivery proof'])->assertConflict();
$this->login('manager','stock');$this->getJson('/api/workshop-stock')->assertConflict();
$tenant=$this->f['tenant'];$company=$this->f['sibling'];$unit=$this->f['siblingUnit'];
foreach(['workshop.view','stock.view','workshop_stock.view','workshop_stock.prepare','workshop_stock.confirm'] as $r)IdentityFixture::grant($this->f['manager']->id,$tenant,$company,$r,$unit);
config(['modules.companies.'.$tenant.':'.$company=>['contracted'=>['workshop','stock'],'enabled'=>['workshop','stock']],'workshop_stock.synthetic_sources.'.$tenant.':'.$company.':'.$unit=>$this->binding]);
$token=$this->postJson('/api/context',['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit,'module_id'=>'workshop'])->assertOk()->json('context.token');$this->withHeader('X-SGF-Context',$token);
$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('pagination.total',0)->assertJsonCount(0,'proposals');
$this->prepare()->assertForbidden();$this->confirm($p)->assertForbidden();
$this->assertSame(10000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.ws_claims')->count());
}
public function test_concurrent_confirmation_has_one_claim_and_movement():void
{
$this->concurrentConfirmation(false);
}
public function test_delivery_link_concurrent_confirmation_has_one_claim_without_movement():void
{
$this->concurrentConfirmation(true);
}
private function concurrentConfirmation(bool $delivery,?int $givenProposal=null,?int $workerProposal=null,int $workerStatus=200,bool $received=false):void
{
$p=$givenProposal??($delivery?$this->prepareDelivery($this->previousDelivery()):$this->prepare())->assertOk()->json('id');$this->login('reader');$scope=['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']];
$dir=base_path('../private/workshop-stock-'.bin2hex(random_bytes(6)));mkdir($dir,0700,true);$input=$dir.'/worker.json';$application='workshop_stock_'.bin2hex(random_bytes(6));$process=null;
file_put_contents($input,json_encode(['actor'=>$this->f['reader']->id,'scope'=>$scope,'binding'=>$this->binding,'proposal'=>$workerProposal??$p,'application'=>$application,'received_in_warehouse'=>$received],JSON_THROW_ON_ERROR));DB::beginTransaction();
try{$winner=$this->confirm($p,$received)->assertOk()->json('movement_id');$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/workshop-stock-worker.php'),$input],[0=>['pipe','r'],1=>['file',$dir.'/stdout','w'],2=>['file',$dir.'/stderr','w']],$pipes,base_path());$this->assertIsResource($process);fclose($pipes[0]);$parent=(int)DB::selectOne('SELECT pg_backend_pid() AS id')->id;$wait=null;$deadline=microtime(true)+10;
do{DB::select('SELECT pg_stat_clear_snapshot()');$wait=DB::selectOne("SELECT to_json(pg_blocking_pids(pid)) AS blockers FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'",[$application]);if(!$wait)usleep(25000);}while(!$wait&&microtime(true)<$deadline);$this->assertNotNull($wait);$this->assertContains($parent,json_decode($wait->blockers,true));DB::commit();
$deadline=microtime(true)+15;do{$status=proc_get_status($process);if(!$status['running'])break;usleep(25000);}while(microtime(true)<$deadline);$this->assertFalse($status['running']);$exit=proc_close($process);$process=null;if($exit===-1)$exit=$status['exitcode'];$this->assertSame(0,$exit);$result=json_decode(file_get_contents($input.'.result'),true);$this->assertSame($workerStatus,$result['status']);if($workerStatus===200){$this->assertTrue($result['result']['replayed']);$this->assertSame($winner,$result['result']['movement_id']);}
}finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();}
if($givenProposal===null){$this->assertSame(8000,$this->balance());$this->assertSame(1,DB::table($this->schema.'.ws_claims')->count());}
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
$file=realpath($argv[1]??'');$private=realpath(getcwd().'/../private');if(!$file||!$private||!str_starts_with($file,$private.DIRECTORY_SEPARATOR))throw new RuntimeException('Wrong QA worker input');
require getcwd().'/vendor/autoload.php';$app=require getcwd().'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
App\Shared\Testing\TestingDatabaseGuard::assertSafe();$p=json_decode(file_get_contents($file),true,512,JSON_THROW_ON_ERROR);
$db=Illuminate\Support\Facades\DB::connection();$i=$db->selectOne("SELECT current_setting('cluster_name') AS name");if($i->name!=='sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN'))throw new RuntimeException('Wrong QA cluster');
$db->select("SELECT set_config('application_name',?,false)",[$p['application']]);$db->statement("SET lock_timeout='8s'");
$actor=App\Models\User::findOrFail($p['actor']);auth()->guard()->setUser($actor);app(App\Shared\Context\CompanyContext::class)->selectFor($actor->id,$p['scope']['tenant_id'],$p['scope']['company_id'],$p['scope']['unit_id']);
config(['workshop_stock.synthetic_sources.'.implode(':',$p['scope'])=>$p['binding']]);
try{$service=app(App\Modules\Workshop\WorkshopStock::class);$action=$p['action']??'confirm';$result=$action==='confirm'?$service->confirm($p['proposal'],'Synthetic independent concurrent confirmation.',(bool)($p['received_in_warehouse']??false)):$service->close($p['proposal'],$action,(int)$p['state_version'],$p['source_hash'],$p['reason']);$out=['status'=>200,'result'=>$result];}
catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$out=['status'=>$e->getStatusCode()];}
file_put_contents($file.'.result',json_encode($out,JSON_THROW_ON_ERROR));