Aceita fila administrativa de associacoes com checkpoint local

This commit is contained in:
2026-11-02 11:20:13 +02:00
parent fcf0ada227
commit 340f3bb5fa
15 changed files with 635 additions and 67 deletions
@@ -0,0 +1,170 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
#[\PHPUnit\Framework\Attributes\Group('modular-focused')]
final class ModuleAssociationReviewTest extends TestCase
{
use DatabaseTransactions;
private array $f;
protected function setUp(): void {
parent::setUp(); $this->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');
}
}
}
}
}