f=InvoiceFixture::create(); $this->key=(string)Str::uuid(); $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 review() { return $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/drafts/'.$this->f['draft'].'/review',['series_id'=>$this->f['series'],'rule_id'=>$this->f['rule']]); } private function issue(string $hash,?string $key=null,array $changes=[]) { return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key ?? $this->key])->postJson('/api/service-invoices/drafts/'.$this->f['draft'].'/issue',array_replace(['series_id'=>$this->f['series'],'rule_id'=>$this->f['rule'],'review_hash'=>$hash],$changes)); } public function test_atomic_issuance_pdf_snapshot_replay_and_correction_request(): void { $review=$this->review()->assertOk()->assertJsonPath('ready',true); $this->assertDatabaseCount('si_invoices',0); $hash=$review->json('review_hash'); $id=$this->issue($hash)->assertOk()->assertJsonPath('number',1)->json('id'); $this->issue($hash)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true); $this->issue(str_repeat('a',64))->assertStatus(409); $this->issue($hash,(string)Str::uuid())->assertStatus(409); $show=$this->getJson('/api/service-invoices/'.$id)->assertOk()->assertJsonPath('snapshot.notice',ServiceInvoicing::NOTICE)->assertJsonPath('snapshot.calculation.total_document_proposed','9106.00')->assertJsonPath('snapshot.assessment_period',substr($this->f['today'],0,7)); $this->assertSame($show->json('snapshot.document_lines'),$show->json('document_lines')); $pdf=$this->get('/api/service-invoices/'.$id.'/pdf')->assertOk()->assertHeader('Content-Type','application/pdf')->getContent(); $this->assertStringStartsWith('%PDF-1.4',$pdf); $this->assertSame($show->json('pdf_sha256'),hash('sha256',$pdf)); $before=DB::table('si_invoices')->get()->toJson(); $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->patchJson('/api/a/clients/'.$this->f['client'],['version'=>1,'name'=>'Changed after issue'])->assertOk(); $this->assertSame($pdf,$this->get('/api/service-invoices/'.$id.'/pdf')->assertOk()->getContent()); $this->getJson('/api/service-invoices/'.$id)->assertOk()->assertJsonPath('snapshot.document.client.name','Cliente sintético Horizonte'); $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/'.$id.'/corrections',['reason'=>'Pedido sintético sem efeito monetário'])->assertOk()->assertJsonPath('state','pending_no_fiscal_effect'); $this->assertSame($before,DB::table('si_invoices')->get()->toJson()); $this->assertDatabaseCount('si_events',1); $this->assertDatabaseCount('si_outbox',1); $this->assertDatabaseCount('si_commands',1); } public function test_real_environment_cannot_issue_or_configure_even_with_grants(): void { $hash=$this->review()->assertOk()->json('review_hash'); $this->app->instance('env','local'); $this->withHeader('X-CSRF-TOKEN',session()->token()); $this->issue($hash)->assertForbidden(); $this->review()->assertForbidden(); $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/proposals',[])->assertForbidden(); $this->getJson('/api/service-invoices')->assertOk()->assertJsonPath('real_blocked',true)->assertJsonPath('invoices',[]); $this->assertDatabaseCount('si_invoices',0); try { ServiceInvoicing::guard(); $this->fail('CLI bypass allowed'); } catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); } } public function test_reader_separation_revocation_and_cross_company_access(): void { $hash=$this->review()->assertOk()->json('review_hash'); $id=$this->issue($hash)->assertOk()->json('id'); $this->login('reader'); $this->getJson('/api/service-invoices/'.$id)->assertOk(); $this->issue($hash)->assertForbidden(); $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/'.$id.'/corrections',['reason'=>'No right'])->assertForbidden(); $this->login(); DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('permission','service_invoices.issue')->delete(); $this->issue($hash)->assertForbidden(); $this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk(); $this->getJson('/api/service-invoices/'.$id)->assertForbidden(); } public function test_stale_revision_date_changes_missing_data_and_pending_rule_refuse(): void { $hash=$this->review()->assertOk()->json('review_hash'); $this->issue($hash,null,['number'=>999])->assertUnprocessable(); $this->travel(1)->days(); $this->issue($hash)->assertStatus(409); $this->review()->assertOk()->assertJsonPath('ready',false); $this->travelBack(); $this->withHeader('X-SGF-Context',$this->token)->patchJson('/api/document-preparation/client/'.$this->f['client'],['version'=>1,'nuit'=>null])->assertOk(); $this->issue($hash)->assertStatus(409); $this->review()->assertOk()->assertJsonPath('ready',false); $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/drafts/'.$this->f['draft'].'/review',['series_id'=>$this->f['series'],'rule_id'=>$this->f['series']])->assertOk()->assertJsonPath('ready',false); $this->assertDatabaseCount('si_invoices',0); $this->assertDatabaseCount('si_counters',0); } public function test_failure_during_outbox_rolls_back_number_invoice_and_audit(): void { $hash=$this->review()->assertOk()->json('review_hash'); DB::unprepared("CREATE OR REPLACE FUNCTION si_test_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'SYNTHETIC_FAILURE'; END; $$; CREATE TRIGGER si_failure BEFORE INSERT ON si_outbox FOR EACH ROW EXECUTE FUNCTION si_test_fail()"); $this->issue($hash)->assertStatus(500); $this->assertDatabaseCount('si_invoices',0); $this->assertDatabaseCount('si_counters',0); $this->assertDatabaseCount('si_events',0); $this->assertDatabaseCount('si_commands',0); $this->assertSame(0,DB::table('identity_audit')->where('action','service_invoice.test_issued')->count()); DB::unprepared('DROP TRIGGER si_failure ON si_outbox'); $this->issue($hash)->assertOk()->assertJsonPath('number',1); } public function test_database_immutability_and_b1_snapshot_are_preserved(): void { $id=$this->issue($this->review()->assertOk()->json('review_hash'))->assertOk()->json('id'); foreach(['si_invoices','si_proposals','si_decisions','si_events','si_outbox','si_commands'] as $table) { try { DB::transaction(fn()=>DB::table($table)->delete()); $this->fail('History deleted'); } catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); } } try { DB::transaction(fn()=>DB::table('si_invoices')->where('id',$id)->update(['unit_id'=>$this->f['hidden']])); $this->fail('Mass transfer allowed'); } catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); } $this->assertDatabaseCount('b_internal_confirmations',0); } public function test_b1_confirmed_draft_is_not_converted_or_changed(): void { $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->postJson('/api/b1/billing-drafts/'.$this->f['draft'].'/confirm-internal',['version'=>1])->assertOk(); $before=DB::table('b_internal_confirmations')->get()->toJson(); $this->review()->assertStatus(409); $this->assertDatabaseCount('si_invoices',0); $this->assertSame($before,DB::table('b_internal_confirmations')->get()->toJson()); } public function test_closed_series_new_approval_changes_revision_and_has_no_counter_reset(): void { $old=$this->review()->assertOk()->json('review_hash'); $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/proposals',['kind'=>'series','identifier'=>'TEST-SERIE-ISOLADA','previous_id'=>$this->f['series'],'data'=>['code'=>'TEST-SERIE-ISOLADA','first_number'=>1,'state'=>'closed','valid_from'=>$this->f['start'],'evidence'=>'Encerramento sintético aprovado.']])->assertOk(); $new=(int)DB::table('si_proposals')->max('id'); $this->login('reader'); $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/proposals/'.$new.'/decision',['version'=>2,'decision'=>'approved','reason'=>'Encerrar sem apagar contador.'])->assertOk(); $this->login(); $this->issue($old)->assertStatus(409); $this->review()->assertOk()->assertJsonPath('ready',false); $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/proposals',['kind'=>'series','identifier'=>'TEST-SERIE-ISOLADA','previous_id'=>$new,'data'=>['code'=>'TEST-SERIE-ISOLADA','first_number'=>10,'state'=>'open','valid_from'=>$this->f['start'],'evidence'=>'Illegal reset attempt.']])->assertUnprocessable(); } public function test_pdf_encoding_and_long_text_are_not_silently_lost(): void { $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->patchJson('/api/a/clients/'.$this->f['client'],['version'=>1,'name'=>'测试'])->assertOk(); $this->review()->assertOk()->assertJsonPath('ready',false); $this->assertDatabaseCount('si_invoices',0); $pdf=new \App\Modules\Services\InvoicePdf(); // Encoding refusal must be explicit, rather than generating replacement glyphs. $this->assertFalse(\App\Modules\Services\InvoicePdf::supports([['text','测试']])); } }