239 lines
22 KiB
PHP
239 lines
22 KiB
PHP
<?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');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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();
|
|
}
|
|
}
|