Files
SGF/tests/Feature/DirectCostsTest.php
T
nala d10ba06edc Close isolated direct cost trial by delegated technical acceptance
Preserve verified 275 MZN and incomplete 175 MZN demonstrations. Keep 090000 and four real cost grants unapplied; real costing and fiscal blocks remain intact.
2026-10-27 12:39:41 +02:00

76 lines
13 KiB
PHP

<?php
namespace Tests\Feature;
use App\Modules\Workshop\DirectCosts;
use App\Modules\Workshop\WorkshopWorkflow;
use App\Shared\Context\CompanyContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\DirectCostFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class DirectCostsTest extends TestCase {
use RefreshDatabase;
private array $f;private string $token;
protected function setUp(): void {parent::setUp();$this->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']);
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'direct_costs.validate',$this->f['unit']);
$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();
}
}