Files
SGF/tests/Feature/DocumentPreviewTest.php
T
2026-11-02 08:06:45 +02:00

170 lines
14 KiB
PHP

<?php
namespace Tests\Feature;
use App\Modules\Services\DocumentPreview;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\Support\DocumentPreviewFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class DocumentPreviewTest extends TestCase
{
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'commercial'; }
use RefreshDatabase;
private array $fixture;
private string $token;
private int $client;
private int $service;
private int $source;
private int $billing;
protected function setUp(): void
{
parent::setUp(); $this->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, '<svg onload="alert(1)"></svg>'); $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());
}
}