f=IdentityFixture::create('-review-'.bin2hex(random_bytes(5))); foreach(['clients','services','service_drafts','billing_drafts'] as $area) foreach(['view','create','edit'] as $action) IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],"$area.$action",str_ends_with($area,'_drafts')?$this->f['unit']:null); $actor=$this->f['manager']; $this->actingAs($actor)->withSession(['auth_version'=>$actor->auth_version]); $this->select(); } private function select(): string { $token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit'],'module_id'=>null])->assertOk()->json('context.token'); $this->withHeader('X-SGF-Context',$token); return $token; } private function record(string $area='clients',array $extra=[]): int { return DB::table('a_'.$area)->insertGetId($extra+['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>str_ends_with($area,'_drafts')?$this->f['unit']:null,'name'=>'Synthetic review','version'=>1,'data'=>'{}','created_at'=>now(),'updated_at'=>now()]); } private function preview(int $id,string $area='clients',array $extra=[]) { return $this->postJson('/api/module-records/preview',$extra+['area'=>$area,'id'=>$id,'version'=>1,'module_id'=>'commercial','operation'=>'associate','reason'=>'Explicit synthetic review']); } private function confirm(string $token) {return $this->postJson('/api/module-records',['review_token'=>$token]);} private function draft(): array { $client=$this->record();$service=$this->record('services'); $data=json_encode(['currency'=>'MZN','tax_status'=>'pending','accounting_status'=>'not_processed','lines'=>[['service_id'=>$service,'quantity'=>'1','unit_price'=>'10']]]); $draft=$this->record('service_drafts',['client_id'=>$client,'data'=>$data]); return [$draft,$client,$service]; } public function test_server_filters_pagination_and_originals_are_preserved(): void { $ids=[];for($i=0;$i<7;$i++)$ids[]=$this->record('clients',['name'=>'Queue '.$i]); $before=DB::table('a_clients')->whereIn('id',$ids)->orderBy('id')->get()->toJson(); $this->getJson('/api/module-records?area=clients&state=pending&per_page=3&page=2&search=Queue')->assertOk()->assertJsonPath('pagination.total',7)->assertJsonPath('records.clients.0.id',$ids[3])->assertJsonCount(3,'records.clients'); $this->confirm($this->preview($ids[0])->assertOk()->json('review_token'))->assertOk(); $this->getJson('/api/module-records?state=associated')->assertOk()->assertJsonPath('pagination.total',1); $this->getJson('/api/module-records?state=pending')->assertOk()->assertJsonPath('pagination.total',6); $this->confirm($this->preview($ids[0],'clients',['operation'=>'remove'])->assertOk()->json('review_token'))->assertOk(); $this->getJson('/api/module-records?state=removed')->assertOk()->assertJsonPath('records.clients.0.id',$ids[0]); $this->getJson('/api/module-records?per_page=101')->assertUnprocessable(); $this->getJson('/api/module-records?page=0')->assertUnprocessable(); $this->assertSame($before,DB::table('a_clients')->whereIn('id',$ids)->orderBy('id')->get()->toJson()); } public function test_preview_is_mandatory_and_individual_history_and_retry_are_exact(): void { $id=$this->record();$other=$this->record();$preview=$this->preview($id)->assertOk()->assertJsonPath('before',[])->assertJsonPath('after',['commercial']); $this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'commercial','reason'=>'Bypass'])->assertUnprocessable(); $token=$preview->json('review_token');$event=$this->confirm($token)->assertOk()->assertJsonPath('replayed',false)->json('event_id'); $this->confirm($token)->assertOk()->assertJsonPath('replayed',true)->assertJsonPath('event_id',$event); $row=DB::table('identity_audit')->where('id',$event)->first();$d=json_decode($row->details,true); $this->assertSame($this->f['manager']->id,$row->actor_id);$this->assertNotNull($row->created_at); $this->assertSame([],$d['before_modules']);$this->assertSame(['commercial'],$d['after_modules']);$this->assertSame('Explicit synthetic review',$d['reason']); $this->assertSame(1,DB::table('identity_audit')->whereRaw("details::jsonb->>'decision_id' = ?",[$d['decision_id']])->count()); $this->assertSame(0,DB::table('identity_audit')->where('action','module.record.associated')->whereRaw("details::jsonb->>'record_id' = ?",[(string)$other])->count()); $this->preview($id,'clients',['reason'=>' '])->assertUnprocessable(); $this->preview($id,'clients',['module_id'=>'invented'])->assertForbidden(); $this->confirm($token.'tampered')->assertUnprocessable(); $this->confirm(\Illuminate\Support\Facades\Crypt::encryptString('{}'))->assertUnprocessable(); } public function test_stale_record_competing_reviews_and_context_are_rejected(): void { $id=$this->record();$a=$this->preview($id)->assertOk()->json('review_token');$b=$this->preview($id)->assertOk()->json('review_token'); $this->confirm($a)->assertOk();$this->confirm($b)->assertConflict(); $fresh=$this->preview($id)->assertOk()->json('review_token'); DB::table('a_clients')->where('id',$id)->update(['name'=>'Changed','version'=>2]); $this->confirm($fresh)->assertConflict();$this->preview($id)->assertConflict();$this->confirm($a)->assertConflict(); $id=$this->record();$token=$this->preview($id)->assertOk()->json('review_token');$this->select();$this->confirm($token)->assertConflict(); } public function test_dependencies_are_rechecked_and_never_propagated(): void { [$draft,$client,$service]=$this->draft(); $token=$this->preview($draft,'service_drafts')->assertOk()->assertJsonCount(3,'dependencies')->json('review_token'); DB::table('a_services')->where('id',$service)->update(['version'=>2,'name'=>'Changed dependency']); $this->confirm($token)->assertConflict(); $token=$this->preview($draft,'service_drafts')->assertOk()->json('review_token');$this->confirm($token)->assertOk(); $this->getJson('/api/module-records?area=clients&state=pending')->assertOk()->assertJsonPath('records.clients.0.id',$client); $this->getJson('/api/module-records?area=services&state=pending')->assertOk()->assertJsonPath('records.services.0.id',$service); $preview=$this->preview($draft,'service_drafts')->assertOk()->json('review_token'); $this->confirm($this->preview($client)->assertOk()->json('review_token'))->assertOk(); $this->confirm($preview)->assertConflict(); } public function test_company_unit_permissions_and_dependency_visibility(): void { $foreign=$this->record('clients',['company_id'=>$this->f['sibling']]);$id=$this->record(); $this->getJson('/api/module-records')->assertOk()->assertJsonPath('pagination.total',1)->assertJsonPath('records.clients.0.id',$id); $this->preview($foreign)->assertForbidden();$this->getJson('/api/module-records?unit_id='.$this->f['hidden'])->assertForbidden(); [$draft]=$this->draft(); $hidden=$this->record('service_drafts',['client_id'=>DB::table('a_service_drafts')->where('id',$draft)->value('client_id'),'data'=>DB::table('a_service_drafts')->where('id',$draft)->value('data'),'unit_id'=>$this->f['hidden']]); $this->getJson('/api/module-records?area=service_drafts')->assertOk()->assertJsonPath('pagination.total',1); $this->preview($hidden,'service_drafts')->assertForbidden(); $token=$this->preview($id)->assertOk()->json('review_token'); DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('company_id',$this->f['company'])->where('permission','clients.edit')->delete(); $this->confirm($token)->assertForbidden(); DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('company_id',$this->f['company'])->where('permission','services.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->preview($draft,'service_drafts')->assertForbidden(); $reader=$this->f['reader'];$this->actingAs($reader)->withSession(['auth_version'=>$reader->auth_version]);$this->select(); $this->getJson('/api/module-records')->assertForbidden();$this->preview($id)->assertForbidden(); } public function test_audit_failure_rolls_back_and_same_review_can_recover(): void { $id=$this->record();$token=$this->preview($id)->assertOk()->json('review_token'); DB::unprepared("CREATE FUNCTION pg_temp.fail_module_review() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='module.record.associated' THEN RAISE EXCEPTION 'synthetic audit failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER synthetic_review_failure AFTER INSERT ON identity_audit FOR EACH ROW EXECUTE FUNCTION pg_temp.fail_module_review()"); $this->confirm($token)->assertStatus(500); $this->getJson('/api/module-records?state=associated')->assertOk()->assertJsonPath('pagination.total',0); DB::unprepared('DROP TRIGGER synthetic_review_failure ON identity_audit'); $this->confirm($token)->assertOk()->assertJsonPath('replayed',false);$this->confirm($token)->assertOk()->assertJsonPath('replayed',true); } public function test_expiry_configuration_scope_injection_and_stale_read_context(): void { $id=$this->record();$token=$this->preview($id)->assertOk()->json('review_token'); $p=json_decode(\Illuminate\Support\Facades\Crypt::decryptString($token),true);$p['expires']=time()-1; $this->confirm(\Illuminate\Support\Facades\Crypt::encryptString(json_encode($p)))->assertConflict(); $this->getJson('/api/module-records?company_id='.$this->f['sibling'])->assertUnprocessable(); config(['modules.contracted'=>[]]);$this->confirm($token)->assertForbidden(); $this->withHeader('X-SGF-Context','obsolete')->getJson('/api/module-records')->assertConflict(); } public function test_bounded_synthetic_volume_and_literal_search(): void { $ids=[];for($i=0;$i<137;$i++)$ids[]=$this->record('clients',['name'=>'Volume '.$i]); $this->record('clients',['tenant_id'=>$this->f['otherTenant'],'company_id'=>$this->f['foreign'],'name'=>'Foreign volume']); $first=$this->getJson('/api/module-records?per_page=100&search=Volume')->assertOk()->assertJsonPath('pagination.total',137)->assertJsonCount(100,'records.clients')->json('records.clients'); $second=$this->getJson('/api/module-records?per_page=100&page=2&search=Volume')->assertOk()->assertJsonPath('pagination.total',137)->assertJsonCount(37,'records.clients')->json('records.clients'); $this->assertSame($ids,array_column([...$first,...$second],'id')); $this->getJson('/api/module-records?search=%25')->assertOk()->assertJsonPath('pagination.total',0); $this->record('clients',['name'=>'Literal % marker']); $this->getJson('/api/module-records?search=%25')->assertOk()->assertJsonPath('pagination.total',1); } public function test_real_concurrent_confirmations_serialize_and_duplicate_retry_has_one_event(): void { // Only this disposable regression DB is committed so separate PHP workers // can see the synthetic fixture. No persistent demo is addressed. $this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D',(string)getenv('SGF_ISOLATED_REGRESSION_RUN')); $context=$this->select(); foreach ([false,true] as $sameReview) { $id=$this->record(); $a=$this->preview($id)->assertOk()->json('review_token'); $b=$sameReview?$a:$this->preview($id)->assertOk()->json('review_token'); DB::commit(); DB::beginTransaction(); DB::table('companies')->where('id',$this->f['company'])->lockForUpdate()->firstOrFail(); $jobs=[]; try { foreach ([$a,$b] as $token) { $file=tempnam(sys_get_temp_dir(),'sgf-assoc-'); file_put_contents($file,json_encode(['actor'=>$this->f['manager']->id,'tenant'=>$this->f['tenant'],'company'=>$this->f['company'],'unit'=>$this->f['unit'],'context'=>$context,'review_token'=>$token])); $pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/association-confirm-worker.php'),$file],[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes,base_path()); $this->assertIsResource($process); fclose($pipes[0]);$jobs[]=[$process,$pipes,$file]; } $waiting=0;$deadline=microtime(true)+15; do { DB::select('SELECT pg_stat_clear_snapshot()'); $waiting=(int)DB::selectOne("SELECT count(*) AS n FROM pg_stat_activity WHERE datname=current_database() AND application_name='sgf_association_worker' AND wait_event_type='Lock'")->n; if ($waiting>=2) break; usleep(50000); } while (microtime(true)<$deadline); DB::commit();DB::beginTransaction(); $results=[]; foreach ($jobs as [$process,$pipes,$file]) { $out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]); $this->assertSame(0,proc_close($process),$err.$out);$results[]=json_decode($out,true,512,JSON_THROW_ON_ERROR); } $this->assertSame(2,$waiting,'Both independent workers must contend on the company lock: '.json_encode($results)); $statuses=array_column($results,'status');sort($statuses); $this->assertSame($sameReview?[200,200]:[200,409],$statuses); if($sameReview)$this->assertCount(1,array_filter($results,fn($r)=>$r['replayed'])); $this->assertSame(1,DB::table('identity_audit')->where('tenant_id',$this->f['tenant'])->where('company_id',$this->f['company'])->where('action','module.record.associated')->whereRaw("details::jsonb->>'record_id' = ?",[(string)$id])->count()); } finally { foreach ($jobs as [$process,$pipes,$file]) { if(is_resource($process)){proc_terminate($process);foreach($pipes as $pipe)if(is_resource($pipe))fclose($pipe);proc_close($process);} if(is_file($file))unlink($file);if(is_file($file.'.ready'))unlink($file.'.ready'); } } } } }