fixture = InternalConfirmationFixture::create(); $this->login(); $this->client = $this->write('clients', ['name' => 'Cliente histórico sintético'])->assertCreated()->json('id'); $this->service = $this->write('services', ['name' => 'Serviço histórico sintético'])->assertCreated()->json('id'); $this->source = $this->write('service_drafts', ['client_id' => $this->client, 'lines' => [['service_id' => $this->service, 'quantity' => '1.25', 'unit_price' => '0.00000001']]])->assertCreated()->json('id'); $this->billing = $this->write('billing_drafts', ['source_id' => $this->source])->assertCreated()->json('id'); } private function login(string $kind = 'manager'): void { $this->postJson('/api/login', ['email' => $this->fixture[$kind]->email, 'password' => $this->fixture['password']])->assertOk(); $this->select($this->fixture['unit']); } private function select(?int $unit): void { $this->token = $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['company'], 'unit_id' => $unit])->assertOk()->json('context.token'); } private function write(string $area, array $input, ?int $id = null) { $this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()]); return $id ? $this->patchJson("/api/a/$area/$id", $input) : $this->postJson("/api/a/$area", $input); } private function confirm(int $version = 1, ?string $key = null, ?int $id = null) { return $this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => $key ?? (string) Str::uuid()])->postJson('/api/b1/billing-drafts/'.($id ?? $this->billing).'/confirm-internal', ['version' => $version]); } private function document(): array { return $this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->json('confirmation'); } public function test_internal_confirmation_captures_unknowns_without_financial_effects(): void { $this->confirm()->assertOk()->assertJsonPath('replayed', false); $doc = $this->document(); $this->assertSame(InternalConfirmation::LABEL, $doc['label']); $this->assertStringStartsWith('INT-B1-', $doc['internal_reference']); $this->assertNull($doc['snapshot']['data']['currency']); $this->assertSame('0.0000000125', $doc['snapshot']['base_amount']); $this->assertSame('not_issued', $doc['snapshot']['fiscal_status']); $this->assertSame('blocked_pending', $doc['snapshot']['accounting_status']); foreach (['currency_missing','tax_rule_pending','accounting_rule_pending','accounts_pending','recognition_pending'] as $code) { $this->assertArrayHasKey($code, $doc['snapshot']['pending']); } $this->assertSame('b1.billing_draft.confirmed_internal', $doc['event']['type']); $this->assertSame('captured', $doc['event']['state']); $this->assertDatabaseHas('a_service_drafts', ['id' => $this->source, 'state' => 'draft', 'version' => 1]); $this->assertDatabaseHas('a_billing_drafts', ['id' => $this->billing, 'state' => 'draft', 'version' => 1]); $this->assertSame(5, DB::table('a_events')->count()); $this->assertFalse(\Illuminate\Support\Facades\Schema::hasTable('journal_entries')); } public function test_snapshot_preserves_catalogues_and_source_after_later_changes(): void { $this->confirm()->assertOk(); $before = $this->document(); $this->write('clients', ['name' => 'Cliente alterado', 'version' => 1], $this->client)->assertOk(); $this->write('services', ['name' => 'Serviço alterado', 'version' => 1], $this->service)->assertOk(); $this->write('service_drafts', ['client_id' => $this->client, 'version' => 1, 'lines' => [['service_id' => $this->service, 'quantity' => '2', 'unit_price' => '3']]], $this->source)->assertOk(); $this->assertSame($before, $this->document()); $this->assertSame('Cliente histórico sintético', $before['snapshot']['client']['name']); } public function test_reader_and_a_manager_without_new_permission_cannot_confirm(): void { $this->login('reader'); $this->confirm()->assertForbidden(); $this->login(); DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', InternalConfirmation::PERMISSION)->delete(); $this->confirm()->assertForbidden(); // same session, next request $this->assertSame(0, DB::table('b_internal_confirmations')->count()); $this->write('clients', ['name' => 'A continua autorizada'])->assertCreated(); } public function test_new_permission_cannot_be_delegated_via_a_access_management(): void { $this->withHeader('X-SGF-Context', $this->token)->postJson('/api/a/billing_drafts/access', ['email' => $this->fixture['reader']->email, 'action' => 'confirm_internal', 'grant' => true, 'reason' => 'Não autorizado'])->assertUnprocessable(); } public function test_old_version_old_context_and_manipulated_scope_are_refused(): void { $this->confirm(2)->assertStatus(409); $old = $this->token; $this->select(null); $this->withHeaders(['X-SGF-Context' => $old, 'Idempotency-Key' => (string) Str::uuid()])->postJson("/api/b1/billing-drafts/$this->billing/confirm-internal", ['version' => 1])->assertStatus(409); $this->select($this->fixture['unit']); $this->withHeaders(['X-SGF-Context' => $this->token])->postJson("/api/b1/billing-drafts/$this->billing/confirm-internal", ['version' => 1, 'company_id' => $this->fixture['sibling']])->assertUnprocessable(); } public function test_cross_unit_and_same_tenant_other_company_are_refused(): void { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], 'unit.view', $this->fixture['hidden']); foreach (['billing_drafts.view', InternalConfirmation::PERMISSION] as $permission) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], $permission, $this->fixture['hidden']); } $this->select($this->fixture['hidden']); $this->confirm()->assertForbidden(); IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['sibling'], 'company.view'); foreach (['unit.view','billing_drafts.view', InternalConfirmation::PERMISSION] as $permission) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['sibling'], $permission, $this->fixture['siblingUnit']); } $this->token = $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['sibling'], 'unit_id' => $this->fixture['siblingUnit']])->assertOk()->json('context.token'); $this->confirm()->assertForbidden(); $this->getJson("/api/a/billing_drafts/$this->billing")->assertForbidden(); } public function test_repetition_recovers_result_and_changed_key_content_conflicts(): void { $key = (string) Str::uuid(); $first = $this->confirm(key: $key)->assertOk()->json(); $this->confirm(key: $key)->assertOk()->assertJsonPath('id', $first['id'])->assertJsonPath('replayed', true); $this->confirm(2, $key)->assertStatus(409); $this->confirm()->assertOk()->assertJsonPath('id', $first['id']); $this->assertSame(1, DB::table('b_internal_confirmations')->count()); $this->assertSame(1, DB::table('a_events')->where('type', 'b1.billing_draft.confirmed_internal')->count()); } public function test_incomplete_lines_and_changed_source_block_confirmation_but_not_unknown_currency(): void { $this->write('billing_drafts', ['version' => 1, 'lines' => [['service_id' => $this->service, 'quantity' => null, 'unit_price' => null]]], $this->billing)->assertOk(); $this->confirm(2)->assertUnprocessable(); $this->write('billing_drafts', ['version' => 2, 'reload_source' => true], $this->billing)->assertOk(); $this->write('service_drafts', ['client_id' => $this->client, 'version' => 1, 'lines' => [['service_id' => $this->service, 'quantity' => '1', 'unit_price' => '2']]], $this->source)->assertOk(); $this->confirm(3)->assertStatus(409); $this->assertSame(0, DB::table('b_internal_confirmations')->count()); } public function test_confirmed_draft_cannot_be_edited_via_api(): void { $this->confirm()->assertOk(); $this->write('billing_drafts', ['version' => 1, 'reload_source' => true], $this->billing)->assertStatus(409); $this->deleteJson("/api/a/billing_drafts/$this->billing")->assertStatus(405); } public function test_sql_bulk_update_of_confirmed_draft_is_refused(): void { $this->confirm()->assertOk(); $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_B_CONFIRMED_IMMUTABLE'); DB::table('a_billing_drafts')->update(['name' => 'Manipulado', 'version' => 2]); } public function test_sql_delete_of_confirmed_draft_is_refused(): void { $this->confirm()->assertOk(); $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_B_CONFIRMED_IMMUTABLE'); DB::table('a_billing_drafts')->delete(); } public function test_sql_snapshot_update_is_refused(): void { $this->confirm()->assertOk(); $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_APPEND_ONLY_AUDIT'); DB::table('b_internal_confirmations')->update(['snapshot' => '{}']); } public function test_sql_snapshot_delete_is_refused(): void { $this->confirm()->assertOk(); $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_APPEND_ONLY_AUDIT'); DB::table('b_internal_confirmations')->delete(); } public function test_outbox_failure_rolls_back_entire_confirmation(): void { $counts = []; foreach (['a_history','a_events','a_outbox','a_pending','a_commands','identity_audit'] as $table) { $counts[$table] = DB::table($table)->count(); } DB::unprepared("CREATE OR REPLACE FUNCTION sgf_b1_test_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'SYNTHETIC_B1_FAILURE'; END; \$\$; CREATE TRIGGER b1_synthetic_failure BEFORE INSERT ON a_outbox FOR EACH ROW EXECUTE FUNCTION sgf_b1_test_fail();"); $this->confirm()->assertStatus(500); $this->assertSame(0, DB::table('b_internal_confirmations')->count()); foreach ($counts as $table => $count) { $this->assertSame($count, DB::table($table)->count()); } } }