86 lines
15 KiB
PHP
86 lines
15 KiB
PHP
<?php
|
|
namespace Tests\Feature;
|
|
use App\Modules\Industry\{IndustrialBudgets,IndustrialCosts};
|
|
use App\Shared\Context\CompanyContext;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Tests\Support\{IndustrialBudgetFixture,IdentityFixture};
|
|
use Tests\TestCase;
|
|
final class IndustrialBudgetsTest extends TestCase {
|
|
use \Tests\Support\UsesModuleFixture;
|
|
protected function moduleFixture(): string { return 'finance'; }
|
|
use RefreshDatabase;
|
|
private array $f;private string $token;
|
|
protected function setUp(): void {parent::setUp();$this->f=IndustrialBudgetFixture::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 $lot=null){return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/industrial-budgets/lots/'.($lot??$this->f['lot']).'/'.$action,$in);}
|
|
private function proposal(array $extra=[]): array {return array_replace(['version'=>1,'previous_id'=>null,'dimensions'=>app(IndustrialBudgets::class)->dimensions(DB::table('ic_lots')->find($this->f['lot'])),'amounts'=>['material'=>'900','labour'=>'600','allocated'=>'300'],'evidence'=>'Hipótese retrospectiva sintética, sem causas presumidas.'],$extra);}
|
|
private function approve(int $id,int $version=1): void {$this->login('reader');$this->send('decisions',['id'=>$id,'version'=>$version,'decision'=>'approved','reason'=>'Validação exacta sintética.'])->assertOk();$this->login();}
|
|
private function comparison(int $b,array $extra=[]): array {return array_replace(['version'=>1,'previous_id'=>null,'budget_id'=>$b,'budget_version'=>1,'result_id'=>$this->f['result'],'result_version'=>1],$extra);}
|
|
private function snapshot(int $id): array {return json_decode(DB::table('ib_comparisons')->find($id)->snapshot,true);}
|
|
public function test_full_scenario_exact_reconciliation_roles_and_no_new_consumption(): void {
|
|
$before=[];foreach(['ic_inputs','ic_results','wo_entries','dc_calculations'] as $t)$before[$t]=DB::table($t)->count();
|
|
$b=$this->send('proposals',$this->proposal())->assertOk()->json('id');$this->send('comparisons',$this->comparison($b))->assertStatus(409);
|
|
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'industrial_budgets.validate',$this->f['unit']);$this->send('decisions',['id'=>$b,'version'=>1,'decision'=>'approved','reason'=>'Auto validação recusada.'])->assertForbidden();$this->approve($b);
|
|
$c=$this->send('comparisons',$this->comparison($b))->assertOk()->json('id');$s=$this->snapshot($c);
|
|
foreach(['material'=>'100','labour'=>'-100','allocated'=>'0'] as $k=>$v)$this->assertSame($v,$s['rows'][$k]['delta']);$this->assertSame('100/9',$s['rows']['material']['percent']);$this->assertSame('-50/3',$s['rows']['labour']['percent']);$this->assertSame('0',$s['total_delta']);$this->assertTrue($s['balanced']);$this->assertSame('300',$s['residual_separate']);$this->assertSame('2100',$s['actual_reconciliation']['known_origins_exact']);
|
|
foreach($before as $t=>$n)$this->assertSame($n,DB::table($t)->count());$this->login('reader');$this->getJson('/api/industrial-budgets')->assertOk()->assertJsonPath('lots.0.comparisons.0.current',true);$this->send('proposals',$this->proposal())->assertForbidden();$this->send('comparisons',$this->comparison($b))->assertForbidden();
|
|
}
|
|
public function test_equal_above_below_zero_unknown_and_nonfinite_percentages(): void {
|
|
$r=['material'=>'1000','labour'=>'500','allocated'=>'300'];
|
|
foreach([[['1000','500','300'],'0','0'],[['900','450','270'],'180','100/9'],[['1100','550','330'],'-180','-100/11'],[['0','500','300'],'1000','125'],[['0','0','0'],'1800',null]] as [$p,$delta,$pct]){$s=IndustrialBudgets::variance(array_combine(array_keys($r),$p),$r);$this->assertSame($delta,$s['total_delta']);$this->assertSame($pct,$s['total_percent']);$this->assertTrue($s['balanced']);}
|
|
$s=IndustrialBudgets::variance(['material'=>'0','labour'=>'0','allocated'=>'0'],['material'=>'0','labour'=>'0','allocated'=>'0']);$this->assertSame('0',$s['total_delta']);$this->assertSame('zero_base',$s['total_percent_state']);
|
|
$s=IndustrialBudgets::variance(['material'=>null,'labour'=>'600','allocated'=>'300'],$r);$this->assertNull($s['rows']['material']['delta']);$this->assertNull($s['total_delta']);$this->assertSame('-100',$s['paired_delta']);$this->assertSame('unknown',$s['total_percent_state']);
|
|
}
|
|
public function test_missing_material_is_incomplete_and_old_comparison_becomes_historical(): void {
|
|
$b=$this->send('proposals',$this->proposal())->assertOk()->json('id');$this->approve($b);$c=$this->send('comparisons',$this->comparison($b))->assertOk()->json('id');$old=DB::table('ib_comparisons')->find($c)->snapshot;
|
|
$refs=$this->f['refs'];$refs['material_rate']=null;app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$r=app(IndustrialCosts::class)->calculate($this->f['lot'],$this->f['manager']->id,['version'=>2,'previous_id'=>$this->f['result'],'currency'=>'MZN','refs'=>$refs],(string)Str::uuid())['id'];
|
|
app(CompanyContext::class)->selectFor($this->f['reader']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);app(IndustrialCosts::class)->decide($this->f['lot'],$this->f['reader']->id,['type'=>'result','id'=>$r,'version'=>2,'decision'=>'approved','reason'=>'Só perímetro conhecido.'],(string)Str::uuid());$this->login();
|
|
$this->getJson('/api/industrial-budgets')->assertOk()->assertJsonPath('lots.0.comparisons.0.current',false);$this->send('comparisons',$this->comparison($b,['version'=>2,'previous_id'=>$c]))->assertStatus(409);
|
|
$p=$this->send('comparisons',$this->comparison($b,['version'=>2,'previous_id'=>$c,'result_id'=>$r,'result_version'=>2]))->assertOk()->json('id');$s=$this->snapshot($p);$this->assertTrue($s['incomplete']);$this->assertNull($s['total_delta']);$this->assertNull($s['rows']['material']['delta']);$this->assertSame('800',$s['actual_known']);$this->assertSame('-100',$s['paired_delta']);$this->assertSame($old,DB::table('ib_comparisons')->find($c)->snapshot);
|
|
}
|
|
public function test_versions_refusal_dimensions_and_old_approval_not_transferred(): void {
|
|
$b=$this->send('proposals',$this->proposal())->assertOk()->json('id');$this->approve($b);$c=$this->send('comparisons',$this->comparison($b))->assertOk()->json('id');$old=DB::table('ib_budgets')->find($b)->amounts;
|
|
foreach(['product_id'=>999,'lot_id'=>999,'tenant_id'=>999,'company_id'=>999,'unit_id'=>999,'quantity'=>'12','unit'=>'KG','currency'=>'USD','period_start'=>'2026-09-01','period_end'=>'2026-11-01','scope'=>'all_origins'] as $k=>$v){$d=$this->proposal(['version'=>2,'previous_id'=>$b]);$d['dimensions'][$k]=$v;$this->send('proposals',$d)->assertStatus(409);}
|
|
$next=$this->send('proposals',$this->proposal(['version'=>2,'previous_id'=>$b,'amounts'=>['material'=>'1000','labour'=>'500','allocated'=>'300'],'evidence'=>'Révision après constat, original préservé.']))->assertOk()->json('id');
|
|
$this->send('comparisons',$this->comparison($next,['version'=>2,'previous_id'=>$c,'budget_version'=>2]))->assertStatus(409);$this->send('comparisons',$this->comparison($b,['version'=>2,'previous_id'=>$c]))->assertStatus(409);$this->getJson('/api/industrial-budgets')->assertOk()->assertJsonPath('lots.0.comparisons.0.current',false);$this->assertSame($old,DB::table('ib_budgets')->find($b)->amounts);
|
|
$this->login('reader');$this->send('decisions',['id'=>$next,'version'=>1,'decision'=>'approved','reason'=>'Wrong version'])->assertStatus(409);$this->send('decisions',['id'=>$next,'version'=>2,'decision'=>'refused','reason'=>'Refus explicite.'])->assertOk();$this->login();$this->send('comparisons',$this->comparison($next,['version'=>2,'previous_id'=>$c,'budget_version'=>2]))->assertStatus(409);
|
|
}
|
|
public function test_replay_content_conflict_rollback_and_recovery(): void {
|
|
$d=$this->proposal();$key=(string)Str::uuid();$id=$this->send('proposals',$d,$key)->assertOk()->json('id');$this->send('proposals',array_reverse($d,true),$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);$this->send('proposals',array_replace($d,['evidence'=>'Other content']),$key)->assertStatus(409);
|
|
$this->approve($id);$input=$this->comparison($id);$key=(string)Str::uuid();DB::unprepared("CREATE OR REPLACE FUNCTION sgf_ib_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'IB_OUTBOX_FAIL'; END; \$\$; CREATE TRIGGER ib_fail BEFORE INSERT ON ib_outbox FOR EACH ROW EXECUTE FUNCTION sgf_ib_fail()");
|
|
$n=DB::table('identity_audit')->count();$this->send('comparisons',$input,$key)->assertStatus(500);$this->assertSame(0,DB::table('ib_comparisons')->count());$this->assertSame($n,DB::table('identity_audit')->count());DB::unprepared('DROP TRIGGER ib_fail ON ib_outbox');$c=$this->send('comparisons',$input,$key)->assertOk()->json('id');$this->send('comparisons',$input,$key)->assertOk()->assertJsonPath('id',$c)->assertJsonPath('replayed',true);$this->assertSame(1,DB::table('ib_comparisons')->count());
|
|
}
|
|
public function test_isolation_revocation_next_request_and_no_inherited_permissions(): void {
|
|
$this->send('proposals',$this->proposal(),null,999999)->assertForbidden();$b=$this->send('proposals',$this->proposal())->assertOk()->json('id');
|
|
DB::table('permission_grants')->where(['user_id'=>$this->f['manager']->id,'permission'=>'industrial_budgets.prepare'])->delete();$this->send('proposals',$this->proposal())->assertForbidden();
|
|
$this->login('reader');DB::table('permission_grants')->where(['user_id'=>$this->f['reader']->id,'permission'=>'industrial_budgets.validate'])->delete();$this->send('decisions',['id'=>$b,'version'=>1,'decision'=>'approved','reason'=>'Revoked'])->assertForbidden();
|
|
DB::table('permission_grants')->where(['user_id'=>$this->f['reader']->id,'permission'=>'industrial_budgets.view'])->delete();$this->getJson('/api/industrial-budgets')->assertForbidden();
|
|
}
|
|
public function test_sql_immutability_and_normal_environment_guard(): void {
|
|
$b=$this->send('proposals',$this->proposal())->assertOk()->json('id');foreach(['UPDATE ib_budgets SET evidence=\'changed\' WHERE id='.$b,'DELETE FROM ib_budgets WHERE id='.$b] as $sql){try{DB::transaction(fn()=>DB::unprepared($sql));$this->fail('SQL immutable guard absent');}catch(\Illuminate\Database\QueryException $e){$this->assertNotEmpty($e->getMessage());}}
|
|
$this->app->detectEnvironment(fn()=>'local');
|
|
try{IndustrialBudgets::guard();$this->fail('Real costing allowed');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(403,$e->getStatusCode());}
|
|
app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$this->assertTrue(app(IndustrialBudgets::class)->listing($this->f['manager']->id)['blocked']);
|
|
}
|
|
public function test_source_change_wrong_quantity_and_comparison_permission_revocation(): void {
|
|
$b=$this->send('proposals',$this->proposal())->assertOk()->json('id');$this->approve($b);$key=(string)Str::uuid();$input=$this->comparison($b);$this->send('comparisons',$input,$key)->assertOk();
|
|
DB::table('permission_grants')->where(['user_id'=>$this->f['manager']->id,'permission'=>'industrial_budget_comparisons.prepare'])->delete();$this->send('comparisons',$input,$key)->assertForbidden();IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'industrial_budget_comparisons.prepare',$this->f['unit']);
|
|
DB::table('permission_grants')->where(['user_id'=>$this->f['manager']->id,'permission'=>'industrial_budget_comparisons.view'])->delete();$this->send('comparisons',$input,$key)->assertForbidden();$this->getJson('/api/industrial-budgets')->assertOk()->assertJsonCount(0,'lots.0.comparisons');IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'industrial_budget_comparisons.view',$this->f['unit']);
|
|
app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$cost=app(IndustrialCosts::class);$old=DB::table('ic_inputs')->find($this->f['refs']['production']['id']);$data=json_decode($old->data,true);$data['quantity']='12';$p=$cost->input($this->f['lot'],$this->f['manager']->id,['kind'=>'production','source_ref'=>$old->source_ref,'version'=>2,'previous_id'=>$old->id,'data'=>$data,'evidence'=>'Different quantity, no normalization.'],(string)Str::uuid())['id'];
|
|
$this->getJson('/api/industrial-budgets')->assertOk()->assertJsonPath('lots.0.comparisons.0.current',false);
|
|
app(CompanyContext::class)->selectFor($this->f['reader']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$cost->decide($this->f['lot'],$this->f['reader']->id,['type'=>'input','id'=>$p,'version'=>2,'decision'=>'approved','reason'=>'Synthetic quantity revision.'],(string)Str::uuid());
|
|
app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$refs=$this->f['refs'];$refs['production']=['id'=>$p,'version'=>2];$r=$cost->calculate($this->f['lot'],$this->f['manager']->id,['version'=>2,'previous_id'=>$this->f['result'],'currency'=>'MZN','refs'=>$refs],(string)Str::uuid())['id'];
|
|
app(CompanyContext::class)->selectFor($this->f['reader']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$cost->decide($this->f['lot'],$this->f['reader']->id,['type'=>'result','id'=>$r,'version'=>2,'decision'=>'approved','reason'=>'12 units validated only for source result.'],(string)Str::uuid());$this->login();$this->send('comparisons',$this->comparison($b,['version'=>2,'previous_id'=>DB::table('ib_comparisons')->value('id'),'result_id'=>$r,'result_version'=>2]))->assertStatus(409);
|
|
}
|
|
public function test_other_company_unit_context_and_invalid_amounts_are_refused(): void {
|
|
foreach([-1,0,'-1','1e3','1.000000001','10000000000000000'] as $v){$d=$this->proposal();$d['amounts']['material']=$v;$this->send('proposals',$d)->assertStatus(422);}
|
|
$oldToken=$this->token;$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk();$this->send('proposals',$this->proposal())->assertStatus(409);$this->login();
|
|
DB::table('permission_grants')->where(['user_id'=>$this->f['manager']->id,'permission'=>'industrial_budgets.view'])->delete();$this->getJson('/api/industrial-budgets')->assertForbidden();
|
|
foreach(['industrial_costs.view','industrial_budgets.view','industrial_budgets.prepare'] as $p)IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['sibling'],$p,$this->f['siblingUnit']);
|
|
app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['sibling'],$this->f['siblingUnit']);
|
|
try{app(IndustrialBudgets::class)->propose($this->f['lot'],$this->f['manager']->id,$this->proposal(),(string)Str::uuid());$this->fail('Cross company lot allowed');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(403,$e->getStatusCode());}
|
|
}
|
|
}
|