Files
SGF/tests/Feature/WorkshopCorrectionsTest.php
T
2026-10-29 18:43:29 +02:00

51 lines
9.4 KiB
PHP

<?php
namespace Tests\Feature;
use App\Modules\Workshop\{WorkshopCorrections,WorkshopCommercialState};
use App\Modules\Services\{ServiceWorkflow,InternalConfirmation};
use App\Shared\Context\CompanyContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\{WorkshopCorrectionFixture,IdentityFixture};
use Tests\TestCase;
final class WorkshopCorrectionsTest extends TestCase {
use RefreshDatabase;
private array $f;private string $token;
protected function setUp(): void {parent::setUp();$this->f=WorkshopCorrectionFixture::create();$this->login();}
private function login(string $who='manager'): void {$this->postJson('/api/login',['email'=>$this->f[$who]->email,'password'=>$this->f['password']])->assertOk();$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']])->assertOk()->json('context.token');}
private function send(string $action,array $in,?string $key=null,?int $g=null){return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/workshop-corrections/'.($g??$this->f['generation']).'/'.$action,$in);}
private function input(string $kind='replace',array $extra=[]): array {return array_replace(['version'=>1,'previous_id'=>null,'kind'=>$kind,'billing_id'=>$this->f['billing_draft'],'billing_version'=>1,'service_id'=>$this->f['service_draft'],'service_version'=>1,'reason'=>'Correcção comercial sintética, original preservado.','lines'=>$kind==='replace'?array_map(fn($id,$price)=>['entry_id'=>$id,'service_id'=>$this->f['service'],'service_version'=>1,'price'=>$price,'price_source'=>'WORKSHOP-SALES-SYNTHETIC-v2','description'=>'Descrição comercial corrigida.'],[$this->f['time'],$this->f['material']],['200','80']):[]],$extra);}
private function review(int $id,int $version=1): void {$this->login('reader');$this->send('reviews',['id'=>$id,'version'=>$version,'decision'=>'approved','reason'=>'Revisão comercial distinta da versão exacta.'])->assertOk();$this->login();}
public function test_immutable_replacement_510_and_cancel_preserve_claims_and_original(): void {
$old=DB::table('a_billing_drafts')->find($this->f['billing_draft'])->data;$n=DB::table('wo_entries')->count();$p=$this->send('requests',$this->input())->assertOk()->json('id');$this->send('applications',['id'=>$p,'version'=>1])->assertStatus(409);$this->review($p);$key=(string)Str::uuid();$a=$this->send('applications',['id'=>$p,'version'=>1],$key)->assertOk()->assertJsonPath('base_exact','510');$this->send('applications',['id'=>$p,'version'=>1],$key)->assertOk()->assertJsonPath('id',$a->json('id'))->assertJsonPath('replayed',true);
$this->assertSame($old,DB::table('a_billing_drafts')->find($this->f['billing_draft'])->data);$this->assertSame('superseded',WorkshopCommercialState::state('billing_drafts',$this->f['billing_draft']));$this->assertSame('current',WorkshopCommercialState::state('billing_drafts',$a->json('billing_draft_id')));$this->assertSame(2,DB::table('wb_claims')->count());$this->assertSame($n,DB::table('wo_entries')->count());
$cancel=$this->send('requests',$this->input('cancel',['version'=>2,'previous_id'=>$p,'service_id'=>$a->json('service_draft_id'),'billing_id'=>$a->json('billing_draft_id')]))->assertOk()->json('id');$this->review($cancel,2);$this->send('applications',['id'=>$cancel,'version'=>2])->assertOk()->assertJsonPath('kind','cancel');$this->assertSame('cancelled',WorkshopCommercialState::state('billing_drafts',$a->json('billing_draft_id')));$this->send('requests',$this->input())->assertStatus(409);$this->assertSame(2,DB::table('wb_claims')->count());$this->assertSame(0,DB::table('b_internal_confirmations')->count());
}
public function test_self_review_roles_and_revocation_on_replay(): void {
$key=(string)Str::uuid();$p=$this->send('requests',$this->input(),$key)->assertOk()->json('id');IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'workshop_corrections.review',$this->f['unit']);$this->send('reviews',['id'=>$p,'version'=>1,'decision'=>'approved','reason'=>'Self review'])->assertForbidden();$this->login('reader');$this->send('requests',$this->input())->assertForbidden();$this->send('applications',['id'=>$p,'version'=>1])->assertForbidden();$this->login();DB::table('permission_grants')->where(['user_id'=>$this->f['manager']->id,'permission'=>'workshop_corrections.request'])->delete();$this->send('requests',$this->input(),$key)->assertForbidden();
}
public function test_successor_and_withdrawal_do_not_inherit_approval(): void {
$p=$this->send('requests',$this->input())->assertOk()->json('id');$this->review($p);$next=$this->send('requests',$this->input('replace',['version'=>2,'previous_id'=>$p]))->assertOk()->json('id');$this->send('applications',['id'=>$p,'version'=>1])->assertStatus(409);$this->send('applications',['id'=>$next,'version'=>2])->assertStatus(409);$withdraw=$this->send('requests',$this->input('withdraw',['version'=>3,'previous_id'=>$next]))->assertOk()->json('id');$this->login('reader');$this->send('reviews',['id'=>$withdraw,'version'=>3,'decision'=>'approved','reason'=>'Withdraw cannot be applied'])->assertStatus(409);$this->assertSame('current',WorkshopCommercialState::state('billing_drafts',$this->f['billing_draft']));
}
public function test_rollback_atomic_destinations_and_recovery(): void {
$p=$this->send('requests',$this->input())->assertOk()->json('id');$this->review($p);$count=DB::table('a_billing_drafts')->count();DB::unprepared("CREATE OR REPLACE FUNCTION sgf_wc_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'WC_OUTBOX_FAIL'; END; \$\$; CREATE TRIGGER wc_fail BEFORE INSERT ON wc_outbox FOR EACH ROW EXECUTE FUNCTION sgf_wc_fail()");$key=(string)Str::uuid();$this->send('applications',['id'=>$p,'version'=>1],$key)->assertStatus(500);$this->assertSame($count,DB::table('a_billing_drafts')->count());$this->assertSame('current',WorkshopCommercialState::state('billing_drafts',$this->f['billing_draft']));$this->assertSame(0,DB::table('wc_applications')->count());DB::unprepared('DROP TRIGGER wc_fail ON wc_outbox');$this->send('applications',['id'=>$p,'version'=>1],$key)->assertOk();
}
public function test_superseded_b1_and_successor_edit_or_second_billing_refused(): void {
$p=$this->send('requests',$this->input())->assertOk()->json('id');$this->review($p);$a=$this->send('applications',['id'=>$p,'version'=>1])->assertOk();app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);
try{app(InternalConfirmation::class)->confirm($this->f['billing_draft'],$this->f['manager']->id,1,(string)Str::uuid());$this->fail('Historical B1 allowed');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(409,$e->getStatusCode());}
foreach([['billing_drafts',$a->json('billing_draft_id'),['version'=>1,'name'=>'Bypass']],['billing_drafts',null,['source_id'=>$a->json('service_draft_id')]]] as [$area,$id,$input]){try{app(ServiceWorkflow::class)->save($area,$id,$this->f['manager']->id,$input,(string)Str::uuid());$this->fail('Linked destination bypass');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(409,$e->getStatusCode());}}
}
public function test_confirmed_destination_and_incompatible_inputs_refused(): void {
$d=$this->input();$d['lines'][0]['quantity']='2';$this->send('requests',$d)->assertStatus(422);$d=$this->input();$d['client_id']=999;$this->send('requests',$d)->assertStatus(422);$this->send('requests',$this->input(),null,999999)->assertForbidden();
app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);app(InternalConfirmation::class)->confirm($this->f['billing_draft'],$this->f['manager']->id,1,(string)Str::uuid());$this->send('requests',$this->input())->assertStatus(409);
}
public function test_late_b1_dependency_stops_previously_approved_application(): void {
$p=$this->send('requests',$this->input())->assertOk()->json('id');$this->review($p);app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);app(InternalConfirmation::class)->confirm($this->f['billing_draft'],$this->f['manager']->id,1,(string)Str::uuid());$this->send('applications',['id'=>$p,'version'=>1])->assertStatus(409);$this->assertSame(0,DB::table('wc_applications')->count());
}
public function test_zero_is_explicit_missing_is_rejected_and_sql_history_blocked(): void {
$d=$this->input();$d['lines'][1]['price']='';$this->send('requests',$d)->assertStatus(422);$d['lines'][1]['price']='0';$p=$this->send('requests',$d)->assertOk()->json('id');$this->review($p);$a=$this->send('applications',['id'=>$p,'version'=>1])->assertOk()->assertJsonPath('base_exact','350');
foreach(['SELECT sgf_wc_assert_current('.$this->f['billing_draft'].')','UPDATE wc_requests SET kind=\'cancel\' WHERE id='.$p,'DELETE FROM a_billing_drafts WHERE id='.$a->json('billing_draft_id')] as $sql){try{DB::transaction(fn()=>DB::unprepared($sql));$this->fail('SQL protection bypass');}catch(\Illuminate\Database\QueryException $e){$this->assertNotEmpty($e->getMessage());}}
$this->app->detectEnvironment(fn()=>'local');try{WorkshopCorrections::guard();$this->fail('Real corrections allowed');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(403,$e->getStatusCode());}
}
}