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