Aceita fila administrativa de associacoes com checkpoint local
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,13 @@ final class ModuleNavigationTest extends TestCase
|
||||
{
|
||||
return $this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$company??$this->f['company'],'unit_id'=>$this->f['unit'],'module_id'=>$module]);
|
||||
}
|
||||
private function decide(array $input)
|
||||
{
|
||||
$input['operation'] ??= 'associate';
|
||||
$preview = $this->postJson('/api/module-records/preview', $input);
|
||||
if ($preview->status() !== 200) return $preview;
|
||||
return $this->postJson('/api/module-records', ['review_token' => $preview->json('review_token')]);
|
||||
}
|
||||
public function test_explicit_selection_direct_access_and_old_generation(): void
|
||||
{
|
||||
$this->select(null)->assertOk();
|
||||
@@ -95,15 +102,15 @@ final class ModuleNavigationTest extends TestCase
|
||||
$this->withHeader('X-SGF-Context',$token)->getJson('/api/a/clients')->assertOk()->assertJsonPath('records',[]);
|
||||
$this->getJson('/api/a/clients/'.$id)->assertForbidden();
|
||||
$this->getJson('/api/module-records')->assertOk()->assertJsonPath('records.clients.0.id',$id);
|
||||
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Explicit synthetic association'])->assertOk();
|
||||
$this->decide(['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Explicit synthetic association'])->assertOk();
|
||||
$this->getJson('/api/a/clients/'.$id)->assertOk()->assertJsonPath('name','Module proof client');
|
||||
$this->assertSame(1,DB::table('a_clients')->where('id',$id)->value('version'));
|
||||
$this->assertSame(2,DB::table('identity_audit')->where('action','module.record.associated')->where('company_id',$this->f['company'])->count());
|
||||
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Correct association without rewriting original','operation'=>'remove'])->assertOk();
|
||||
$this->decide(['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Correct association without rewriting original','operation'=>'remove'])->assertOk();
|
||||
$this->getJson('/api/a/clients/'.$id)->assertForbidden();
|
||||
$this->getJson('/api/module-records')->assertOk()->assertJsonPath('records.clients.0.modules',['commercial']);
|
||||
$this->assertSame(1,DB::table('a_clients')->where('id',$id)->value('version'));
|
||||
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Re-association explicitly reviewed'])->assertOk();
|
||||
$this->decide(['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Re-association explicitly reviewed'])->assertOk();
|
||||
$this->getJson('/api/a/clients/'.$id)->assertOk();
|
||||
IdentityFixture::grant($actor->id,$this->f['tenant'],$this->f['sibling'],'clients.view');
|
||||
$token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit'],'module_id'=>'commercial'])->assertOk()->json('context.token');
|
||||
@@ -120,7 +127,7 @@ final class ModuleNavigationTest extends TestCase
|
||||
$this->getJson('/api/module-records')->assertForbidden();
|
||||
IdentityFixture::grant($this->f['reader']->id,$this->f['tenant'],$this->f['company'],'access.manage');
|
||||
$this->getJson('/api/module-records')->assertOk()->assertJsonPath('records.clients.0.id',$id)->assertJsonPath('records.clients.0.modules',[]);
|
||||
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'commercial','reason'=>'Cannot assign without edit permission'])->assertForbidden();
|
||||
$this->decide(['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'commercial','reason'=>'Cannot assign without edit permission'])->assertForbidden();
|
||||
$this->assertSame('Existing unassociated record',DB::table('a_clients')->where('id',$id)->value('name'));
|
||||
}
|
||||
|
||||
@@ -141,8 +148,8 @@ final class ModuleNavigationTest extends TestCase
|
||||
$original=$this->getJson('/api/a/billing_drafts/'.$bill)->assertOk()->json();
|
||||
$this->assertSame($draft,$original['source_id']);
|
||||
$token=$this->select('workshop')->assertOk()->json('context.token');
|
||||
$this->withHeader('X-SGF-Context',$token)->postJson('/api/module-records',['area'=>'billing_drafts','id'=>$bill,'version'=>99,'module_id'=>'workshop','reason'=>'Reject stale review'])->assertStatus(409);
|
||||
$this->postJson('/api/module-records',['area'=>'billing_drafts','id'=>$bill,'version'=>1,'module_id'=>'workshop','reason'=>'Explicitly share this preserved draft'])->assertOk();
|
||||
$this->withHeader('X-SGF-Context',$token)->decide(['area'=>'billing_drafts','id'=>$bill,'version'=>99,'module_id'=>'workshop','reason'=>'Reject stale review'])->assertStatus(409);
|
||||
$this->decide(['area'=>'billing_drafts','id'=>$bill,'version'=>1,'module_id'=>'workshop','reason'=>'Explicitly share this preserved draft'])->assertOk();
|
||||
$record=$this->getJson('/api/a/billing_drafts/'.$bill)->assertOk()->json();
|
||||
$this->assertSame($original['data'],$record['data']);
|
||||
$this->assertSame($original['history'],$record['history']);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
// Test-only subprocess, never a route. Its private input contains no credentials.
|
||||
putenv('APP_ENV=testing');
|
||||
require dirname(__DIR__, 2).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__, 2).'/bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
$p = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR);
|
||||
$db = Illuminate\Support\Facades\DB::connection();
|
||||
$cluster = $db->selectOne("SELECT current_setting('cluster_name') AS name")->name;
|
||||
if ($cluster !== 'sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN')) throw new RuntimeException('Wrong test cluster');
|
||||
$db->statement("SET application_name = 'sgf_association_worker'");
|
||||
$context = app(App\Shared\Context\CompanyContext::class);
|
||||
$context->selectFor($p['actor'], $p['tenant'], $p['company'], $p['unit']);
|
||||
$r = Illuminate\Http\Request::create('/api/module-records', 'POST', ['review_token' => $p['review_token']]);
|
||||
$r->headers->set('X-SGF-Context', $p['context']);
|
||||
file_put_contents($argv[1].'.ready', 'ready');
|
||||
try {
|
||||
$result = app(App\Modules\Identity\Services\ModuleAssociationReview::class)->confirm($r);
|
||||
echo json_encode(['status' => 200, ...$result], JSON_THROW_ON_ERROR);
|
||||
} catch (Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e) {
|
||||
echo json_encode(['status' => $e->getStatusCode()], JSON_THROW_ON_ERROR);
|
||||
}
|
||||
Reference in New Issue
Block a user