fixture = DocumentPreviewFixture::create(); $this->login(); $this->client = $this->writeA('clients', ['name' => 'Cliente da pré-visualização'])->assertCreated()->json('id'); $this->service = $this->writeA('services', ['name' => 'Serviço da pré-visualização'])->assertCreated()->json('id'); $this->source = $this->writeA('service_drafts', ['client_id' => $this->client, 'lines' => [['service_id' => $this->service, 'quantity' => '1.25', 'unit_price' => '0.00000001']]])->assertCreated()->json('id'); $this->billing = $this->writeA('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->token = $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['company'], 'unit_id' => $this->fixture['unit']])->assertOk()->json('context.token'); } private function writeA(string $area, array $data, ?int $id = null) { $this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()]); return $id ? $this->patchJson("/api/a/$area/$id", $data) : $this->postJson("/api/a/$area", $data); } private function preview() { return $this->getJson("/api/document-preview/draft/$this->billing"); } private function model(array $changes = [], int $version = 0) { return $this->withHeader('X-SGF-Context', $this->token)->patchJson('/api/document-preview/model', ['version' => $version] + array_replace(DocumentPreview::DEFAULT_MODEL, $changes)); } private function confirm(): void { $this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()])->postJson("/api/b1/billing-drafts/$this->billing/confirm-internal", ['version' => 1])->assertOk(); } private function saveProfile(string $kind, int $id, string $value, int $version = 0): void { $this->withHeader('X-SGF-Context', $this->token)->patchJson("/api/document-preparation/$kind/$id", ['version' => $version, 'nuit' => $value, 'address' => 'Morada sintética', 'locality' => 'Localidade', 'country' => 'País de teste'])->assertOk(); } public function test_preview_reuses_data_exact_arithmetic_and_unknowns_without_writing_business_records(): void { $tables = ['a_clients','a_services','a_service_drafts','a_billing_drafts','a_history','a_events','a_pending','a_outbox','a_commands','b_internal_confirmations','doc_records','doc_history','document_preview_models','identity_audit']; $before = []; foreach ($tables as $table) { $before[$table] = DB::table($table)->count(); } $response = $this->preview()->assertOk()->assertJsonPath('notice', DocumentPreview::NOTICE)->assertJsonPath('data.lines.0.service_name', 'Serviço da pré-visualização')->assertJsonPath('client.name', 'Cliente da pré-visualização')->assertJsonPath('base_amount', '0.0000000125')->assertJsonPath('data.currency', null)->assertJsonPath('model_version', 0); $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control')); $this->assertArrayHasKey('tax_rule_pending', $response->json('pending')); foreach (['fiscal_number','issued_at','tax_amount','final_total'] as $field) { $this->assertArrayNotHasKey($field, $response->json()); } foreach ($before as $table => $count) { $this->assertSame($count, DB::table($table)->count()); } } public function test_missing_values_remain_unknown_while_explicit_zero_is_preserved(): void { $this->writeA('billing_drafts', ['version' => 1, 'lines' => [['service_id' => $this->service, 'quantity' => null, 'unit_price' => null]]], $this->billing)->assertOk(); $this->preview()->assertOk()->assertJsonPath('base_amount', null)->assertJsonPath('data.lines.0.unit_price', null); $this->writeA('billing_drafts', ['version' => 2, 'lines' => [['service_id' => $this->service, 'quantity' => '1', 'unit_price' => '0']]], $this->billing)->assertOk(); $this->preview()->assertOk()->assertJsonPath('base_amount', '0')->assertJsonPath('data.lines.0.unit_price', '0'); } public function test_configuration_is_explicit_reader_is_denied_and_old_version_conflicts(): void { $this->model(['contact_text' => 'Contactos sintéticos'])->assertOk()->assertJsonPath('version', 1); $this->model()->assertStatus(409); $this->login('reader'); $this->getJson('/api/document-preview/model')->assertOk(); $this->model(version: 1)->assertForbidden(); $this->login(); DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', DocumentPreview::CONFIGURE)->delete(); $this->model(version: 1)->assertForbidden(); } public function test_configuration_cannot_hide_notice_or_pending_and_rejects_external_resources(): void { $this->model(['show_notice' => false])->assertUnprocessable(); $this->model(['pending' => []])->assertUnprocessable(); $this->model(['logo_url' => 'https://example.invalid/logo.png'])->assertUnprocessable(); $this->model(['show_contacts' => false, 'show_logo' => false, 'show_payment_instructions' => false])->assertOk(); $result = $this->preview()->assertOk()->assertJsonPath('notice', DocumentPreview::NOTICE)->assertJsonPath('contacts', null); foreach (['tax_rule_pending','instructions_missing','contacts_missing','logo_missing'] as $field) { $this->assertArrayHasKey($field, $result->json('pending')); } } public function test_revision_detects_model_data_and_instruction_changes_without_replacing_prior_response(): void { $before = $this->preview()->assertOk()->json(); $this->getJson("/api/document-preview/draft/$this->billing/revision")->assertOk()->assertExactJson(['revision' => $before['revision']]); $this->model(['observations' => 'Observação sintética nova'])->assertOk(); $after = $this->getJson("/api/document-preview/draft/$this->billing/revision")->assertOk()->json('revision'); $this->assertNotSame($before['revision'], $after); $this->assertSame('', $before['observations']); $this->preview()->assertOk()->assertJsonPath('observations', 'Observação sintética nova'); $this->saveProfile('client', $this->client, 'NUIT-SINTETICO'); $this->assertNotSame($after, $this->getJson("/api/document-preview/draft/$this->billing/revision")->assertOk()->json('revision')); } public function test_selected_instructions_and_price_mode_are_reused_without_fiscal_computation(): void { $marker = 'SYNTHETIC-'.Str::uuid(); $payment = $this->withHeader('X-SGF-Context', $this->token)->patchJson('/api/document-preparation/payment/0', ['version' => 0, 'label' => 'Instruções sintéticas', 'account_number' => $marker])->assertOk()->json('id'); $data = ['version' => 0, 'draft_version' => 1, 'currency' => null, 'price_mode' => 'included', 'units' => ['hora'], 'payment_instruction_ids' => [$payment]]; $this->withHeader('X-SGF-Context', $this->token)->patchJson("/api/document-preparation/billing_drafts/$this->billing", $data)->assertOk(); $before = $this->preview()->assertOk()->assertJsonPath('prepared.price_mode', 'included')->assertJsonPath('base_amount', '0.0000000125'); $this->assertTrue($before->json('payments.0.data.account_number') === $marker); $this->model(['show_payment_instructions' => false])->assertOk(); $this->preview()->assertOk()->assertJsonCount(0, 'payments')->assertJsonPath('base_amount', '0.0000000125'); $this->assertStringNotContainsString($marker, DB::table('identity_audit')->pluck('details')->implode('')); } public function test_history_v1_uses_only_snapshot_and_never_current_profiles_or_model(): void { $this->confirm(); $before = $this->getJson("/api/document-preview/history/$this->billing")->assertOk()->json(); $this->saveProfile('issuer', $this->fixture['company'], 'NOVO-EMITENTE'); $this->saveProfile('client', $this->client, 'NOVO-CLIENTE'); $this->writeA('clients', ['version' => 1, 'name' => 'Nome actual diferente'], $this->client)->assertOk(); $this->model(['contact_text' => 'NÃO HISTÓRICO', 'observations' => 'NÃO CAPTURADO'])->assertOk(); $after = $this->getJson("/api/document-preview/history/$this->billing")->assertOk()->json(); unset($before['consulted_at'], $after['consulted_at']); $this->assertSame($before, $after); $this->assertNull($after['issuer']['name']); $this->assertSame([], $after['issuer']['data']); $this->assertSame('Cliente da pré-visualização', $after['client']['name']); $this->assertNull($after['contacts']); $this->preview()->assertStatus(409); } public function test_history_v2_keeps_captured_profile_and_payment_versions(): void { $this->saveProfile('issuer', $this->fixture['company'], 'EMITENTE-HISTÓRICO'); $this->saveProfile('client', $this->client, 'CLIENTE-HISTÓRICO'); $this->confirm(); $before = $this->getJson("/api/document-preview/history/$this->billing")->assertOk()->assertJsonPath('snapshot_schema', 2)->assertJsonPath('issuer.data.nuit', 'EMITENTE-HISTÓRICO')->json(); $this->saveProfile('issuer', $this->fixture['company'], 'EMITENTE-ACTUAL', 1); $after = $this->getJson("/api/document-preview/history/$this->billing")->assertOk()->json(); unset($before['consulted_at'], $after['consulted_at']); $this->assertSame($before, $after); $this->assertNull($after['issuer']['name']); } public function test_cross_unit_company_and_revoked_access_are_denied_on_next_consultation(): void { $this->preview()->assertOk(); IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], 'unit.view', $this->fixture['hidden']); foreach (['billing_drafts.view','draft_documents.view'] as $permission) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], $permission, $this->fixture['hidden']); } $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['company'], 'unit_id' => $this->fixture['hidden']])->assertOk(); $this->preview()->assertForbidden(); $this->getJson("/api/document-preview/draft/$this->billing/revision")->assertForbidden(); $this->login(); $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['sibling'], 'unit_id' => null])->assertOk(); $this->preview()->assertForbidden(); $this->login(); DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', 'issuer_documents.view')->delete(); $this->getJson("/api/document-preview/draft/$this->billing/revision")->assertForbidden(); } public function test_logo_is_local_png_only_and_no_history_reconstructs_it(): void { Storage::fake('local'); $path = 'document-logos/company-'.$this->fixture['company'].'.png'; $this->model(['show_logo' => true])->assertOk(); $this->preview()->assertOk()->assertJsonPath('logo', null); Storage::disk('local')->put($path, ''); $this->preview()->assertOk()->assertJsonPath('logo', null); Storage::disk('local')->put($path, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jV0kAAAAASUVORK5CYII=')); $logo = $this->preview()->assertOk()->json('logo'); $this->assertStringStartsWith('data:image/png;base64,', $logo); $this->confirm(); $this->getJson("/api/document-preview/history/$this->billing")->assertOk()->assertJsonPath('logo', null); } public function test_model_scope_cannot_be_transferred_in_bulk(): void { $this->model()->assertOk(); $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_PREVIEW_IMMUTABLE_SCOPE_OR_VERSION'); DB::table('document_preview_models')->update(['company_id' => $this->fixture['sibling'], 'version' => 2]); } public function test_model_history_is_immutable(): void { $this->model()->assertOk(); $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_APPEND_ONLY_AUDIT'); DB::table('document_preview_model_history')->delete(); } public function test_configuration_failure_is_atomic_and_does_not_log_content(): void { DB::unprepared("CREATE OR REPLACE FUNCTION sgf_preview_test_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'SYNTHETIC_PREVIEW_FAILURE'; END; \$\$; CREATE TRIGGER preview_synthetic_fail BEFORE INSERT ON document_preview_model_history FOR EACH ROW EXECUTE FUNCTION sgf_preview_test_fail();"); $this->model(['observations' => 'Texto sintético de falha'])->assertStatus(500); $this->assertSame(0, DB::table('document_preview_models')->count()); $this->assertSame(0, DB::table('document_preview_model_history')->count()); } }