Record explicit workshop acceptance and preserve subsequent synthetic work without accepting receivables. Keep 080000/070000 unapplied, real workshop grants pending and operational issuance blocks intact. Exclude local credentials and real-data preservation reports.
193 lines
20 KiB
PHP
193 lines
20 KiB
PHP
<?php
|
|
namespace Tests\Feature;
|
|
use App\Modules\Services\ServiceReceivables;
|
|
use App\Shared\Context\CompanyContext;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Tests\Support\ReceivablesFixture;
|
|
use Tests\Support\IdentityFixture;
|
|
use Tests\TestCase;
|
|
|
|
final class ServiceReceivablesTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
private array $f; private string $token; private int $ob;
|
|
protected function setUp(): void { parent::setUp(); $this->f=ReceivablesFixture::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 commandPost(string $path,array $data,?string $key=null) {
|
|
return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/service-receivables'.$path,$data);
|
|
}
|
|
private function rev(): int { return isset($this->ob)?DB::table('sr_effects')->where('obligation_id',$this->ob)->count():0; }
|
|
private function proposal(string $kind,array $extra=[],?string $key=null) {
|
|
return $this->commandPost('/proposals',array_replace(['kind'=>$kind,'invoice_id'=>$this->f['invoice'],'currency'=>'MZN','source'=>'TEST-MANUAL','external_id'=>(string)Str::uuid(),'revision'=>$this->rev()],isset($this->ob)?['obligation_id'=>$this->ob]:[],$extra),$key);
|
|
}
|
|
private function decide(int $id,?string $key=null,array $extra=[]) { return $this->commandPost('/'.$id.'/decision',array_replace(['version'=>1,'revision'=>$this->rev(),'decision'=>'approved','reason'=>'Confirmação sintética explícita.'],$extra),$key); }
|
|
private function open(?string $due=null): void {
|
|
$this->ob=$this->proposal('obligation',['due_date'=>$due])->assertOk()->json('id');
|
|
$this->login('reader'); $this->decide($this->ob)->assertOk(); $this->login();
|
|
}
|
|
private function receipt(string $amount): int {
|
|
$id=$this->proposal('receipt',['amount'=>$amount,'date'=>now('Africa/Johannesburg')->format('Y-m-d'),'evidence'=>'Origem artificial sem dinheiro.'])->assertOk()->json('id');
|
|
$this->login('reader'); $this->decide($id)->assertOk(); $this->login(); return $id;
|
|
}
|
|
private function allocate(int $id,string $amount,?string $key=null,?int $rev=null) { return $this->commandPost('/'.$id.'/allocate',['amount'=>$amount,'version'=>1,'revision'=>$rev??$this->rev()],$key); }
|
|
private function ledgerView() { return $this->getJson('/api/service-receivables')->assertOk(); }
|
|
private function correction(string $action,array $data=[]): int { return $this->proposal('correction',['action'=>$action,'reason'=>'Correcção sintética justificada.']+$data)->assertOk()->json('id'); }
|
|
private function observation(int $receipt,string $amount): int {
|
|
return $this->proposal('observation',['receipt_id'=>$receipt,'amount'=>$amount,'date'=>now('Africa/Johannesburg')->format('Y-m-d'),'evidence'=>'Observação artificial independente.'])->assertOk()->json('id');
|
|
}
|
|
public function test_partial_receipts_exact_allocation_idempotency_and_independent_reconciliation(): void {
|
|
$before=DB::table('si_invoices')->get()->toJson(); $this->open(); $a=$this->receipt('400.00');
|
|
$this->ledgerView()->assertJsonPath('obligations.0.balance','1000.00')->assertJsonPath('obligations.0.available','400.00')->assertJsonPath('obligations.0.due_state','unknown');
|
|
$revision=$this->rev(); $key=(string)Str::uuid(); $id=$this->allocate($a,'400.00',$key,$revision)->assertOk()->json('id');
|
|
$this->allocate($a,'400',$key,$revision)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
|
|
$this->allocate($a,'399.99',$key,$revision)->assertStatus(409);
|
|
$b=$this->receipt('600'); $this->allocate($b,'600')->assertOk();
|
|
$this->ledgerView()->assertJsonPath('obligations.0.balance','0.00')->assertJsonPath('obligations.0.received','1000.00')->assertJsonPath('obligations.0.allocated','1000.00');
|
|
$oa=$this->observation($a,'400'); $ob=$this->observation($b,'600');
|
|
$this->login('reader'); $this->decide($oa)->assertOk(); $this->decide($ob)->assertOk();
|
|
$v=$this->ledgerView(); $receipts=$v->json('obligations.0.receipts'); $this->assertTrue($receipts[$a]['reconciled']); $this->assertTrue($receipts[$b]['reconciled']);
|
|
$this->assertSame($before,DB::table('si_invoices')->get()->toJson());
|
|
$this->assertSame(DB::table('sr_commands')->count(),DB::table('sr_events')->count());
|
|
$this->assertSame(DB::table('sr_events')->count(),DB::table('sr_outbox')->count());
|
|
$svc=app(ServiceReceivables::class); $event=DB::table('sr_events')->value('id'); $this->assertTrue($svc->deliver($event)); $this->assertFalse($svc->deliver($event));
|
|
}
|
|
public function test_excess_divergence_no_tolerance_and_duplicate_source(): void {
|
|
$this->open(); $a=$this->receipt('400'); $this->allocate($a,'400')->assertOk(); $b=$this->receipt('700');
|
|
$this->allocate($b,'700')->assertStatus(409); $this->ledgerView()->assertJsonPath('obligations.0.balance','600.00');
|
|
$this->allocate($b,'600')->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.available','100.00')->assertJsonPath('obligations.0.balance','0.00');
|
|
$o=$this->observation($a,'399.99'); $this->login('reader'); $this->decide($o)->assertOk()->assertJsonPath('event','divergence.simulation');
|
|
$this->assertSame('0.01',DB::table('sr_effects')->where('kind','divergence')->value('amount'));
|
|
$this->login(); $r=DB::table('sr_records')->find($b);
|
|
$this->proposal('receipt',['source'=>$r->source,'external_id'=>$r->external_id,'amount'=>'700','date'=>now()->format('Y-m-d'),'evidence'=>'Repeated source'])->assertStatus(409);
|
|
}
|
|
public function test_reduction_reversal_dependencies_and_preserved_original(): void {
|
|
$this->open(); $a=$this->receipt('400'); $af=$this->allocate($a,'400')->assertOk()->json('id');
|
|
$c=$this->correction('reduction',['amount'=>'100','document_ref'=>'TEST-REDUCTION-100','line'=>'1']);
|
|
$this->login('reader'); $this->decide($c)->assertOk(); $this->login();
|
|
$b=$this->receipt('500'); $bf=$this->allocate($b,'500')->assertOk()->json('id');
|
|
$this->ledgerView()->assertJsonPath('obligations.0.balance','0.00')->assertJsonPath('obligations.0.original','1000.00')->assertJsonPath('obligations.0.reduced','100.00');
|
|
$fail=$this->correction('reduction',['amount'=>'100','document_ref'=>'TEST-REDUCTION-200','line'=>'1']);
|
|
$this->login('reader'); $this->decide($fail)->assertStatus(409); $this->login();
|
|
$fail=$this->correction('reverse_receipt',['receipt_id'=>$b]); $this->login('reader'); $this->decide($fail)->assertStatus(409); $this->login();
|
|
$undo=$this->correction('reverse_allocation',['target_effect_id'=>$bf]); $this->login('reader'); $this->decide($undo)->assertOk(); $this->login();
|
|
$this->ledgerView()->assertJsonPath('obligations.0.balance','500.00');
|
|
$undo=$this->correction('reverse_receipt',['receipt_id'=>$b]); $this->login('reader'); $this->decide($undo)->assertOk();
|
|
$this->ledgerView()->assertJsonPath('obligations.0.received','400.00')->assertJsonPath('obligations.0.available','0.00');
|
|
$this->assertDatabaseHas('sr_effects',['id'=>$bf,'kind'=>'allocate','amount'=>'500.00']);
|
|
}
|
|
public function test_permissions_revocation_self_approval_stale_versions_and_cross_scope(): void {
|
|
$this->open(); $a=$this->receipt('400');
|
|
$this->login('reader'); $this->allocate($a,'400')->assertForbidden(); $this->proposal('receipt',[])->assertForbidden();
|
|
$this->login(); IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'service_receivables.confirm',$this->f['unit']);
|
|
$r=$this->proposal('receipt',['amount'=>'100','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic'])->assertOk()->json('id');
|
|
$this->decide($r)->assertForbidden(); $this->allocate($a,'400',null,0)->assertStatus(409);
|
|
$this->commandPost('/'.$a.'/allocate',['amount'=>'400','revision'=>$this->rev(),'version'=>2])->assertUnprocessable();
|
|
$c=$this->correction('due',['due_date'=>'2026-11-10']); $this->allocate($a,'1')->assertOk(); $this->login('reader'); $this->decide($c)->assertStatus(409); $this->login();
|
|
DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('permission','service_receivables.allocate')->delete();
|
|
$this->allocate($a,'1')->assertForbidden();
|
|
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk();
|
|
$this->commandPost('/'.$a.'/allocate',['amount'=>'1','version'=>1,'revision'=>0])->assertStatus(409);
|
|
}
|
|
public function test_invalid_money_environment_and_sql_immutability(): void {
|
|
$this->open();
|
|
foreach(['0','-1','1.001','1e3','10000000000000000'] as $amount) $this->proposal('receipt',['amount'=>$amount,'date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic'])->assertUnprocessable();
|
|
$this->proposal('receipt',['amount'=>'1','currency'=>'USD'])->assertUnprocessable();
|
|
foreach(['sr_records','sr_effects','sr_events','sr_outbox','sr_commands'] as $table) {
|
|
try { DB::transaction(fn()=>DB::table($table)->delete()); $this->fail('Immutable deletion'); } catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
|
|
}
|
|
$this->app->instance('env','local'); $this->withHeader('X-CSRF-TOKEN',session()->token()); $this->proposal('obligation')->assertForbidden();
|
|
$this->ledgerView()->assertJsonPath('blocked',true)->assertJsonPath('obligations',[]);
|
|
try { ServiceReceivables::guard(); $this->fail('CLI bypass'); } catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); }
|
|
}
|
|
public function test_outbox_failure_rolls_back_and_lost_response_replay_recovers(): void {
|
|
$key=(string)Str::uuid(); $input=['kind'=>'obligation','invoice_id'=>$this->f['invoice'],'currency'=>'MZN','source'=>'TEST-FAIL','external_id'=>'failure-1','revision'=>0];
|
|
DB::unprepared("CREATE FUNCTION sr_test_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_FAILURE'; END; $$; CREATE TRIGGER sr_failure BEFORE INSERT ON sr_outbox FOR EACH ROW EXECUTE FUNCTION sr_test_fail()");
|
|
$this->commandPost('/proposals',$input,$key)->assertStatus(500); $this->assertDatabaseCount('sr_records',0); $this->assertDatabaseCount('sr_commands',0); $this->assertDatabaseCount('sr_events',0);
|
|
DB::unprepared('DROP TRIGGER sr_failure ON sr_outbox');
|
|
$id=$this->commandPost('/proposals',$input,$key)->assertOk()->json('id');
|
|
$this->commandPost('/proposals',$input,$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true); $this->assertDatabaseCount('sr_records',1);
|
|
}
|
|
private function sqlDenied(callable $write): void {
|
|
try { DB::transaction($write); $this->fail('SQL invariant bypass'); }
|
|
catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
|
|
}
|
|
public function test_sql_protects_linkage_scale_aggregate_and_history_without_eloquent(): void {
|
|
$this->open(); $a=$this->receipt('700'); $this->allocate($a,'700')->assertOk(); $b=$this->receipt('400');
|
|
$row=(array)DB::table('sr_effects')->where('kind','allocate')->first(); unset($row['id']);
|
|
$row['record_id']=$b; $row['receipt_id']=$b; $row['amount']='400.00'; $row['revision']=$this->rev();
|
|
$this->sqlDenied(fn()=>DB::table('sr_effects')->insert($row));
|
|
$row['amount']='0.001';
|
|
try { DB::transaction(fn()=>DB::table('sr_effects')->insert($row)); $this->fail('Scale silently rounded'); } catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('check constraint',$e->getMessage()); }
|
|
$row['amount']='1.00'; $row['obligation_id']=999999; $this->sqlDenied(fn()=>DB::table('sr_effects')->insert($row));
|
|
$r=(array)DB::table('sr_records')->find($a); unset($r['id']); $r['external_id']='sql-transfer'; $r['unit_id']=$this->f['hidden'];
|
|
$this->sqlDenied(fn()=>DB::table('sr_records')->insert($r));
|
|
$this->sqlDenied(fn()=>DB::table('sr_records')->where('id',$a)->update(['client_id'=>$this->f['client']+1]));
|
|
$this->ledgerView()->assertJsonPath('obligations.0.balance','300.00')->assertJsonPath('obligations.0.allocated','700.00');
|
|
}
|
|
public function test_reconciliation_undo_reuse_and_post_settlement_reduction(): void {
|
|
$this->open(); $a=$this->receipt('400'); $this->allocate($a,'400')->assertOk(); $b=$this->receipt('600'); $bf=$this->allocate($b,'600')->assertOk()->json('id');
|
|
$ob=$this->observation($b,'600'); $this->login('reader'); $cf=$this->decide($ob)->assertOk()->json('id');
|
|
$this->decide($ob)->assertStatus(409); $this->login();
|
|
$other=$this->observation($b,'600'); $this->login('reader'); $this->decide($other)->assertStatus(409); $this->login();
|
|
$reverse=$this->correction('reverse_reconcile',['target_effect_id'=>$cf]); $this->login('reader'); $this->decide($reverse)->assertOk(); $this->login();
|
|
$reverse=$this->correction('reverse_allocation',['target_effect_id'=>$bf]); $this->login('reader'); $key=(string)Str::uuid(); $rev=$this->rev(); $this->decide($reverse,$key)->assertOk();
|
|
$this->decide($reverse,$key,['revision'=>$rev])->assertOk()->assertJsonPath('replayed',true); $this->login();
|
|
$c=$this->correction('reduction',['amount'=>'100','document_ref'=>'TEST-REDUCTION-AFTER','line'=>'1']); $this->login('reader'); $this->decide($c)->assertOk(); $this->login();
|
|
$this->allocate($b,'500')->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.balance','0.00')->assertJsonPath('obligations.0.available','100.00');
|
|
$this->login('reader'); $this->decide($other)->assertOk();
|
|
$this->ledgerView()->assertJsonPath('obligations.0.balance','0.00');
|
|
}
|
|
public function test_due_boundaries_missing_dates_and_consumer_failure_are_explicit(): void {
|
|
$this->open('2026-11-10');
|
|
$this->travelTo(\Carbon\Carbon::parse('2026-11-10 12:00:00','Africa/Johannesburg')); $this->ledgerView()->assertJsonPath('obligations.0.due_state','not_due');
|
|
$this->travelTo(\Carbon\Carbon::parse('2026-11-11 12:00:00','Africa/Johannesburg')); $this->ledgerView()->assertJsonPath('obligations.0.due_state','overdue'); $this->travelBack();
|
|
$a=$this->receipt('1000'); $this->allocate($a,'1000')->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.due_state','settled');
|
|
$event=DB::table('sr_events')->value('id');
|
|
DB::unprepared("CREATE OR REPLACE FUNCTION sr_delivery_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_DELIVERY_FAILURE'; END; $$; CREATE TRIGGER sr_delivery_failure BEFORE INSERT ON sr_deliveries FOR EACH ROW EXECUTE FUNCTION sr_delivery_fail()");
|
|
try { app(ServiceReceivables::class)->deliver($event); $this->fail('Failure not injected'); } catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('TEST_DELIVERY_FAILURE',$e->getMessage()); }
|
|
$this->assertDatabaseCount('sr_deliveries',0); DB::unprepared('DROP TRIGGER sr_delivery_failure ON sr_deliveries');
|
|
$this->assertTrue(app(ServiceReceivables::class)->deliver($event)); $this->assertFalse(app(ServiceReceivables::class)->deliver($event));
|
|
}
|
|
public function test_foreign_identifiers_missing_evidence_and_db_target_guard(): void {
|
|
$this->open();
|
|
$this->proposal('receipt',['amount'=>'1'])->assertUnprocessable();
|
|
$this->proposal('receipt',['amount'=>'1','date'=>'2099-01-01','evidence'=>'Synthetic'])->assertUnprocessable();
|
|
$this->proposal('observation',['amount'=>'1','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic','receipt_id'=>999999])->assertForbidden();
|
|
$this->proposal('receipt',['amount'=>'1','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic','invoice_id'=>999999])->assertForbidden();
|
|
$this->proposal('receipt',['amount'=>'1','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic','client_id'=>999999])->assertUnprocessable();
|
|
$a=$this->receipt('100'); $target=$this->allocate($a,'100')->assertOk()->json('id');
|
|
$reverse=$this->correction('reverse_allocation',['target_effect_id'=>$target]);
|
|
$this->login('reader'); $this->decide($reverse)->assertOk(); $this->login();
|
|
$again=$this->correction('reverse_allocation',['target_effect_id'=>$target]); $this->login('reader'); $this->decide($again)->assertStatus(409);
|
|
$this->ledgerView()->assertJsonPath('obligations.0.balance','1000.00')->assertJsonPath('obligations.0.available','100.00');
|
|
}
|
|
public function test_financial_effect_rollback_live_scope_and_wrong_database_guard(): void {
|
|
$this->open(); $a=$this->receipt('400'); $revision=$this->rev(); $before=DB::table('sr_effects')->count(); $key=(string)Str::uuid();
|
|
DB::unprepared("CREATE OR REPLACE FUNCTION sr_test_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_FAILURE'; END; $$; CREATE TRIGGER sr_failure BEFORE INSERT ON sr_outbox FOR EACH ROW EXECUTE FUNCTION sr_test_fail()");
|
|
$this->allocate($a,'400',$key,$revision)->assertStatus(500); $this->assertDatabaseCount('sr_effects',$before);
|
|
$this->ledgerView()->assertJsonPath('obligations.0.balance','1000.00')->assertJsonPath('obligations.0.available','400.00');
|
|
DB::unprepared('DROP TRIGGER sr_failure ON sr_outbox');
|
|
$this->allocate($a,'400',$key,$revision)->assertOk(); $this->allocate($a,'400',$key,$revision)->assertOk()->assertJsonPath('replayed',true);
|
|
foreach(['view','allocate'] as $right) IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['sibling'],'service_receivables.'.$right,$this->f['siblingUnit']);
|
|
$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk()->json('context.token');
|
|
$this->allocate($a,'1')->assertForbidden(); $this->ledgerView()->assertJsonPath('records',[]);
|
|
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden']])->assertForbidden();
|
|
config(['database.connections.pgsql.database'=>'sgf_tempo_dev']);
|
|
try { ServiceReceivables::guard(); $this->fail('Wrong test destination allowed'); } catch(\RuntimeException $e) { $this->assertStringContainsString('SGF_TEST_DATABASE_REFUSED',$e->getMessage()); }
|
|
}
|
|
public function test_proposed_refused_obligation_is_not_open_and_original_identity_is_frozen(): void {
|
|
$this->ob=$this->proposal('obligation')->assertOk()->json('id');
|
|
$this->ledgerView()->assertJsonPath('obligations.0.opened',false)->assertJsonPath('obligations.0.due_state','not_open');
|
|
$data=json_decode(DB::table('sr_records')->find($this->ob)->data,true);
|
|
$this->assertSame(hash('sha256',DB::table('si_invoices')->find($this->f['invoice'])->snapshot),$data['origin']['snapshot_sha256']);
|
|
$this->login('reader'); $this->decide($this->ob,null,['decision'=>'refused'])->assertOk();
|
|
$this->ledgerView()->assertJsonPath('obligations.0.state','refused')->assertJsonPath('obligations.0.opened',false);
|
|
$this->login(); $this->proposal('receipt',['amount'=>'400','date'=>now()->format('Y-m-d'),'evidence'=>'No open obligation'])->assertStatus(409);
|
|
}
|
|
}
|