f=DirectCostFixture::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 $d,?string $key=null,?int $order=null){return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/direct-costs/orders/'.($order??$this->f['order']).'/'.$action,$d);} private function rate(string $kind,string $amount='50',array $extra=[]){return $this->send('rates',array_replace(['entry_id'=>$this->f[$kind],'entry_version'=>1,'identifier'=>'TEST-'.$kind,'version'=>1,'previous_id'=>null,'currency'=>'MZN','unit'=>$kind==='time'?'H':'UN','amount'=>$amount,'evidence'=>'Taxa sintética, não custo real.'],$extra));} private function decide(string $kind,int $id,array $extra=[]){$r=DB::table($kind==='rate'?'dc_rates':'dc_calculations')->find($id);return $this->send('decisions',array_replace(['kind'=>$kind,'id'=>$id,'version'=>$r->version,'decision'=>'approved','reason'=>'Revisão sintética de custos e taxas.'],$extra));} private function rates(): array {$m=$this->rate('material')->assertOk()->json('id');$t=$this->rate('time','100')->assertOk()->json('id');$this->login('reader');$this->decide('rate',$m)->assertOk();$this->decide('rate',$t)->assertOk();$this->login();return [$m,$t];} private function input(?int $m,?int $t,array $extra=[]): array {return array_replace(['currency'=>'MZN','version'=>1,'previous_id'=>null,'lines'=>[['entry_id'=>$this->f['material'],'entry_version'=>1,'rate_id'=>$m,'rate_version'=>$m?1:null],['entry_id'=>$this->f['time'],'entry_version'=>1,'rate_id'=>$t,'rate_version'=>$t?1:null]]],$extra);} private function listing(){return $this->getJson('/api/direct-costs')->assertOk();} private function snapshot(int $id): array {return json_decode(DB::table('dc_calculations')->find($id)->snapshot,true,64,JSON_THROW_ON_ERROR);} public function test_exact_known_cost_and_distinct_validation_without_new_consumption(): void { $before=DB::table('wo_entries')->get()->toJson();[$m,$t]=$this->rates();$id=$this->send('calculations',$this->input($m,$t))->assertOk()->json('id');$s=$this->snapshot($id); $this->assertSame('275',$s['known_subtotal_exact']);$this->assertSame('275',$s['selected_direct_total_exact']);$this->assertFalse($s['incomplete']);$this->assertSame(['175','100'],array_column($s['lines'],'cost_exact'));$this->assertSame('1.75',$s['lines'][0]['cost_quantity_exact']); $this->decide('calculation',$id)->assertForbidden();$this->login('reader');$this->decide('calculation',$id)->assertOk();$this->assertSame($before,DB::table('wo_entries')->get()->toJson()); $this->assertSame(DB::table('dc_commands')->count(),DB::table('dc_events')->count());$this->assertSame(DB::table('dc_events')->count(),DB::table('dc_outbox')->count()); } public function test_missing_rate_is_not_zero_and_partial_can_be_validated_only_as_partial(): void { [$m,$t]=$this->rates();$id=$this->send('calculations',$this->input(null,$t))->assertOk()->json('id');$s=$this->snapshot($id);$this->assertSame('175',$s['known_subtotal_exact']);$this->assertNull($s['selected_direct_total_exact']);$this->assertTrue($s['incomplete']);$this->assertCount(1,$s['pending']); $this->login('reader');$this->decide('calculation',$id)->assertOk();$this->login();$zero=$this->rate('material','0',['identifier'=>'TEST-ZERO'])->assertOk()->json('id');$this->login('reader');$this->decide('rate',$zero)->assertOk();$this->login();$in=$this->input($zero,$t,['version'=>2,'previous_id'=>$id]);$second=$this->send('calculations',$in)->assertOk()->json('id');$this->assertFalse($this->snapshot($second)['incomplete']);$this->assertSame('0',$this->snapshot($second)['lines'][1]['cost_exact']);$this->assertSame('175',$this->snapshot($second)['selected_direct_total_exact']); } public function test_new_rate_preserves_and_invalidates_approved_calculation_no_latest_default(): void { [$m,$t]=$this->rates();$id=$this->send('calculations',$this->input($m,$t))->assertOk()->json('id');$this->login('reader');$this->decide('calculation',$id)->assertOk();$this->login();$old=DB::table('dc_calculations')->find($id)->snapshot; $next=$this->rate('material','60',['version'=>2,'previous_id'=>$m])->assertOk()->json('id');$this->listing()->assertJsonPath('orders.0.calculations.0.current',false);$this->send('calculations',$this->input($m,$t,['version'=>2,'previous_id'=>$id]))->assertStatus(409); $this->login('reader');$this->decide('rate',$m)->assertStatus(409);$this->decide('rate',$next)->assertOk();$this->login();$in=$this->input($next,$t,['version'=>2,'previous_id'=>$id]);$in['lines'][0]['rate_version']=2;$new=$this->send('calculations',$in)->assertOk()->json('id');$this->assertSame('295',$this->snapshot($new)['known_subtotal_exact']);$this->assertSame($old,DB::table('dc_calculations')->find($id)->snapshot); // A different approved identifier is not silently selected. $this->rate('material','99',['identifier'=>'TEST-OTHER'])->assertOk();$this->listing()->assertJsonPath('orders.0.calculations.1.snapshot.known_subtotal_exact','295'); } public function test_invalid_source_types_unverified_versions_currency_units_and_precision_refused(): void { $delivered=DB::table('wo_entries')->where('order_id',$this->f['order'])->whereRaw("data->>'event'='delivered'")->value('id');$this->rate('material','50',['entry_id'=>$delivered])->assertStatus(422); $this->rate('material','50',['entry_version'=>99])->assertForbidden();$this->rate('material','50',['unit'=>'H'])->assertStatus(422);$this->rate('material','50',['currency'=>'USD'])->assertStatus(422); foreach(['','1.000000001','-1','1e2','1,00'] as $value)$this->rate('material',$value)->assertStatus(422); $this->rate('material','50',['amount'=>50])->assertStatus(422); $e=DB::table('wo_entries')->find($this->f['material']);$d=json_decode($e->data,true);unset($d['policy']);$rev=(int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$e->order_id])->v; app(CompanyContext::class)->selectFor($this->f['technician']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);app(WorkshopWorkflow::class)->record($e->order_id,$this->f['technician']->id,array_replace($d,['version'=>2,'previous_id'=>$e->id,'revision'=>$rev,'quantity'=>'2.1','correction_reason'=>'Correcção sintética de quantidade.']),(string)Str::uuid());$this->login();$this->rate('material')->assertStatus(409);$latest=DB::table('wo_entries')->where('previous_id',$e->id)->first();$this->rate('material','50',['entry_id'=>$latest->id,'entry_version'=>2])->assertStatus(409); } public function test_roles_scope_revocation_and_idempotent_response_recovery(): void { $key=(string)Str::uuid();$d=['entry_id'=>$this->f['material'],'entry_version'=>1,'identifier'=>'TEST-REPLAY','version'=>1,'previous_id'=>null,'currency'=>'MZN','unit'=>'UN','amount'=>'50','evidence'=>'TEST-REPLAY'];$id=$this->send('rates',$d,$key)->assertOk()->json('id');$this->send('rates',$d,$key)->assertOk()->assertJsonPath('replayed',true)->assertJsonPath('id',$id);$this->send('rates',array_replace($d,['amount'=>'51']),$key)->assertStatus(409); $this->send('rates',$d,null,$this->f['order']+999)->assertForbidden();$this->login('reader');$this->rate('time')->assertForbidden();$this->login('technician');$this->getJson('/api/direct-costs')->assertForbidden();$this->login();DB::table('permission_grants')->where(['user_id'=>$this->f['manager']->id,'permission'=>'direct_costs.prepare'])->delete();$this->send('rates',$d,$key)->assertForbidden(); } public function test_failure_rolls_back_rate_audit_event_outbox_and_retry_is_safe(): void { $before=DB::table('dc_rates')->count();DB::unprepared("CREATE FUNCTION dc_fail_test() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'DC_TEST_OUTBOX_FAILURE'; END; \$\$; CREATE TRIGGER dc_fail_test BEFORE INSERT ON dc_outbox FOR EACH ROW EXECUTE FUNCTION dc_fail_test()"); $response=$this->rate('material');$this->assertGreaterThanOrEqual(500,$response->status());$this->assertSame($before,DB::table('dc_rates')->count());$this->assertSame(0,DB::table('dc_commands')->count());$this->assertSame(0,DB::table('dc_events')->count());DB::unprepared('DROP TRIGGER dc_fail_test ON dc_outbox; DROP FUNCTION dc_fail_test()');$this->rate('material')->assertOk(); } public function test_refusal_and_immutable_bulk_changes_and_wrong_database_guard(): void { $id=$this->rate('material')->assertOk()->json('id');$this->login('reader');$this->decide('rate',$id,['decision'=>'refused'])->assertOk();$this->login();$t=$this->rate('time','100')->assertOk()->json('id');$this->login('reader');$this->decide('rate',$t)->assertOk();$this->login();$this->send('calculations',$this->input($id,$t))->assertStatus(409); foreach(['UPDATE dc_rates SET unit_id=999','DELETE FROM dc_rates'] as $sql){try{DB::transaction(fn()=>DB::statement($sql));$this->fail('Immutable SQL accepted');}catch(\Illuminate\Database\QueryException){$this->assertTrue(true);}} $this->app['env']='local';$this->getJson('/api/direct-costs')->assertOk()->assertJsonPath('blocked',true);try{DirectCosts::guard();$this->fail('Normal guard accepted');}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){$this->assertSame(403,$e->getStatusCode());}$this->app['env']='testing';$old=config('database.connections.pgsql.database');config(['database.connections.pgsql.database'=>'sgf_tempo_dev']);try{DirectCosts::guard();$this->fail('Wrong database accepted');}catch(\RuntimeException $e){$this->assertStringContainsString('SGF_TEST_DATABASE_REFUSED',$e->getMessage());}finally{config(['database.connections.pgsql.database'=>$old]);} } public function test_corrected_source_stales_history_exact_nonfinite_cost_and_cross_scope_refusal(): void { [$m,$t]=$this->rates();$id=$this->send('calculations',$this->input($m,$t))->assertOk()->json('id');$original=DB::table('dc_calculations')->find($id)->snapshot; $e=DB::table('wo_entries')->find($this->f['time']);$d=json_decode($e->data,true);unset($d['policy']);$d=array_replace($d,['version'=>2,'previous_id'=>$e->id,'revision'=>(int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$e->order_id])->v,'ends_at'=>$this->f['day'].'T09:01:00+02:00','pauses'=>[],'correction_reason'=>'Um minuto no novo ensaio exacto.']); app(CompanyContext::class)->selectFor($this->f['technician']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$w=app(WorkshopWorkflow::class);$next=$w->record($e->order_id,$this->f['technician']->id,$d,(string)Str::uuid())['id']; $this->login('reader');$this->decide('calculation',$id)->assertStatus(409); app(CompanyContext::class)->selectFor($this->f['reviewer']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$w->verify($next,$this->f['reviewer']->id,['version'=>2,'revision'=>(int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$e->order_id])->v,'decision'=>'verified','reason'=>'Verificação técnica da correcção.'],(string)Str::uuid());$this->login(); $newRate=$this->rate('time','1',['entry_id'=>$next,'entry_version'=>2,'version'=>2,'previous_id'=>$t])->assertOk()->json('id');$this->login('reader');$this->decide('rate',$newRate)->assertOk();$this->login();$in=$this->input(null,$newRate,['version'=>2,'previous_id'=>$id]);$in['lines'][1]=['entry_id'=>$next,'entry_version'=>2,'rate_id'=>$newRate,'rate_version'=>2];$result=$this->send('calculations',$in)->assertOk()->json('id');$this->assertSame('1/60',$this->snapshot($result)['known_subtotal_exact']);$this->assertSame($original,DB::table('dc_calculations')->find($id)->snapshot); // Own tenant does not imply sibling company or hidden unit access. IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'unit.view',$this->f['hidden']);IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'direct_costs.view',$this->f['hidden']);IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'direct_costs.prepare',$this->f['hidden']); $this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden']])->assertOk()->json('context.token');$this->listing()->assertJsonCount(0,'orders');$this->rate('material')->assertForbidden(); $this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk();$this->getJson('/api/direct-costs')->assertForbidden(); } }