Files
SGF/tests/Feature/DocumentPreparationTest.php
T
nala f8d57b52aa Preserva classificacao contabilistica aceite e bases locais
Regista aceitacao funcional do primeiro incremento PGC-NIRF e preserva as dependencias documentais e tecnicas existentes. A validacao do calculo fiscal continua separada e pendente. Sem credenciais, bases operacionais ou publicacao.
2026-10-26 13:47:11 +02:00

232 lines
18 KiB
PHP

<?php
namespace Tests\Feature;
use App\Modules\Services\DocumentPreparation;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\DocumentPreparationFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class DocumentPreparationTest extends TestCase
{
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 = DocumentPreparationFixture::create(); $this->login();
$this->client = $this->writeA('clients', ['name' => 'Cliente documental sintético'])->assertCreated()->json('id');
$this->service = $this->writeA('services', ['name' => 'Serviço documental sintético'])->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->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 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 save(string $kind, int $id, array $data)
{
return $this->withHeader('X-SGF-Context', $this->token)->patchJson("/api/document-preparation/$kind/$id", $data);
}
private function profile(): array { return ['version' => 0, 'nuit' => 'NUIT-SINTETICO', 'address' => 'Morada de teste', 'locality' => 'Local sintético', 'country' => 'País de teste']; }
private function preparation(int $draftVersion = 1): array
{
return ['version' => 0, 'draft_version' => $draftVersion, 'currency' => null, 'service_date' => '2026-11-01', 'period_start' => '2026-11-01', 'period_end' => '2026-11-30', 'payment_terms' => 'Prazo a conferir', 'proposed_due_date' => '2026-11-15', 'price_mode' => 'undefined', 'units' => ['hora'], 'payment_instruction_ids' => []];
}
private function payment(): array { return ['version' => 0, 'label' => 'Instruções sintéticas', 'bank' => 'Banco fictício', 'holder' => 'Titular sintético', 'account_number' => 'SYNTHETIC-'.Str::uuid(), 'currency' => null, 'instructions' => 'Apenas simulação, não executar pagamentos.']; }
private function confirm(): array
{
return $this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()])->postJson("/api/b1/billing-drafts/$this->billing/confirm-internal", ['version' => 1])->assertOk()->json();
}
private function confirmation(): array { return $this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->json('confirmation'); }
public function test_client_starts_with_name_and_existing_names_are_reused_without_duplicate_fields(): void
{
$record = $this->getJson("/api/document-preparation/client/$this->client")->assertOk();
$record->assertJsonPath('version', 0)->assertJsonPath('name', 'Cliente documental sintético');
$this->save('client', $this->client, $this->profile())->assertOk();
$this->writeA('clients', ['name' => 'Nome revisto', 'version' => 1], $this->client)->assertOk();
$record = $this->getJson("/api/document-preparation/client/$this->client")->assertOk();
$record->assertJsonPath('name', 'Nome revisto')->assertJsonPath('data.nuit', 'NUIT-SINTETICO');
$this->assertArrayNotHasKey('name', $record->json('data'));
$this->assertSame(1, DB::table('a_clients')->count());
}
public function test_issuer_and_payment_instructions_are_company_scoped_and_audited_without_values(): void
{
$this->save('issuer', $this->fixture['company'], $this->profile())->assertOk();
$payment = $this->payment(); $id = $this->save('payment', 0, $payment)->assertOk()->json('id');
$this->getJson("/api/document-preparation/payment/$id")->assertOk()->assertJsonPath('data.account_number', $payment['account_number']);
$this->assertStringNotContainsString($payment['account_number'], DB::table('identity_audit')->pluck('details')->implode(''));
$this->assertSame(2, DB::table('doc_history')->count());
$this->select(null); $this->getJson('/api/document-preparation/payment')->assertOk()->assertJsonCount(1, 'records');
}
public function test_preparation_selects_instructions_without_receipt_and_preserves_exact_prices(): void
{
$id = $this->save('payment', 0, $this->payment())->assertOk()->json('id');
$data = $this->preparation(); $data['payment_instruction_ids'] = [$id]; $data['price_mode'] = 'included';
$this->save('billing_drafts', $this->billing, $data)->assertOk();
$this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->assertJsonPath('base_amount', '0.0000000125')->assertJsonPath('data.currency', null)->assertJsonPath('version', 1);
$record = $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk();
$record->assertJsonPath('data.payment_instruction_ids.0', $id)->assertJsonPath('data.price_mode', 'included');
$this->assertFalse(\Illuminate\Support\Facades\Schema::hasTable('receipts'));
$this->assertFalse(\Illuminate\Support\Facades\Schema::hasTable('journal_entries'));
}
public function test_preparation_reuses_source_dates_units_and_instructions_for_new_billing(): void
{
$id = $this->save('payment', 0, $this->payment())->assertOk()->json('id');
$data = $this->preparation(); $data['payment_instruction_ids'] = [$id];
$this->save('service_drafts', $this->source, $data)->assertOk();
$billing = $this->writeA('billing_drafts', ['source_id' => $this->source])->assertCreated()->json('id');
$record = $this->getJson("/api/document-preparation/billing_drafts/$billing")->assertOk();
$record->assertJsonPath('data.units.0', 'hora')->assertJsonPath('data.service_date', '2026-11-01')->assertJsonPath('data.payment_instruction_ids.0', $id)->assertJsonPath('data.source_preparation.version', 1);
}
public function test_completeness_distinguishes_missing_filled_and_pending_without_false_validation(): void
{
$before = $this->getJson("/api/document-preparation/client/$this->client")->assertOk()->json();
$this->assertSame('missing', collect($before['review'])->firstWhere('field', 'nuit')['state']);
$this->save('client', $this->client, $this->profile())->assertOk();
$after = $this->getJson("/api/document-preparation/client/$this->client")->assertOk()->json();
$this->assertSame('filled_not_validated', collect($after['review'])->firstWhere('field', 'nuit')['state']);
$this->assertSame('validation_pending', collect($after['review'])->firstWhere('field', 'legal')['state']);
$data = $this->preparation(); $data['payment_terms'] = 'Pagamento em 30 dias'; $data['proposed_due_date'] = '2026-11-02';
$this->save('billing_drafts', $this->billing, $data)->assertOk();
$record = $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk();
$record->assertJsonPath('data.proposed_due_date', '2026-11-02');
$this->assertSame('validation_pending', collect($record->json('review'))->firstWhere('field', 'terms_due')['state']);
}
public function test_reader_can_consult_but_not_change_any_new_area(): void
{
$payment = $this->save('payment', 0, $this->payment())->assertOk()->json('id');
$this->login('reader');
foreach (['issuer' => $this->fixture['company'], 'client' => $this->client, 'payment' => $payment, 'service_drafts' => $this->source, 'billing_drafts' => $this->billing] as $kind => $id) {
$this->getJson("/api/document-preparation/$kind/$id")->assertOk();
$this->save($kind, $id, ['version' => 0])->assertForbidden();
}
}
public function test_no_implicit_new_rights_and_revocation_affects_next_request(): void
{
DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', 'issuer_documents.view')->delete();
$this->getJson('/api/document-preparation/payment')->assertForbidden();
$this->getJson('/api/document-preparation/issuer/'.$this->fixture['company'])->assertForbidden();
$this->getJson('/api/a/clients')->assertOk();
}
public function test_cross_company_same_tenant_and_cross_unit_are_refused(): void
{
$id = $this->save('payment', 0, $this->payment())->assertOk()->json('id');
IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['sibling'], 'company.view');
foreach (['issuer_documents.view','issuer_documents.edit'] as $permission) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['sibling'], $permission); }
$this->token = $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['sibling'], 'unit_id' => null])->assertOk()->json('context.token');
$this->getJson("/api/document-preparation/payment/$id")->assertForbidden();
$this->getJson('/api/document-preparation/issuer/'.$this->fixture['company'])->assertForbidden();
$this->save('payment', $id, $this->payment())->assertForbidden();
$this->select($this->fixture['unit']);
IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], 'unit.view', $this->fixture['hidden']);
foreach (['draft_documents.view','draft_documents.edit','billing_drafts.view'] as $permission) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], $permission, $this->fixture['hidden']); }
$this->select($this->fixture['hidden']); $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertForbidden();
}
public function test_foreign_payment_selection_and_protected_fields_are_refused(): void
{
$foreign = DB::table('doc_records')->insertGetId(['tenant_id' => $this->fixture['otherTenant'], 'company_id' => $this->fixture['foreign'], 'unit_id' => null, 'kind' => 'payment', 'record_id' => 0, 'version' => 1, 'data' => json_encode(['label' => 'Outra empresa']), 'created_at' => now(), 'updated_at' => now()]);
$data = $this->preparation(); $data['payment_instruction_ids'] = [$foreign];
$this->save('billing_drafts', $this->billing, $data)->assertForbidden();
$this->save('issuer', $this->fixture['company'], $this->profile() + ['company_id' => $this->fixture['sibling']])->assertUnprocessable();
$this->save('payment', 0, $this->payment() + ['password' => 'not-collected'])->assertUnprocessable();
}
public function test_old_profile_draft_version_and_context_are_refused(): void
{
$this->save('client', $this->client, $this->profile())->assertOk();
$this->save('client', $this->client, $this->profile())->assertStatus(409);
$this->save('billing_drafts', $this->billing, $this->preparation(2))->assertStatus(409);
$old = $this->token; $this->select(null);
$this->withHeader('X-SGF-Context', $old)->patchJson('/api/document-preparation/issuer/'.$this->fixture['company'], $this->profile())->assertStatus(409);
}
public function test_currency_is_reused_and_synchronised_without_recalculating_tax(): void
{
$data = $this->preparation(); $data['currency'] = 'TST';
$this->save('billing_drafts', $this->billing, $data)->assertOk();
$this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->assertJsonPath('version', 2)->assertJsonPath('data.currency', 'TST')->assertJsonPath('base_amount', '0.0000000125')->assertJsonPath('data.tax_status', 'pending');
$this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk()->assertJsonPath('data.draft_version', 2);
}
public function test_existing_v1_confirmation_is_unchanged_after_profile_and_payment_updates(): void
{
$this->confirm(); $before = $this->confirmation();
$this->assertArrayNotHasKey('schema_version', $before['snapshot']);
$this->save('issuer', $this->fixture['company'], $this->profile())->assertOk();
$this->save('client', $this->client, $this->profile())->assertOk();
$this->save('payment', 0, $this->payment())->assertOk();
$this->assertSame($before, $this->confirmation());
$this->save('billing_drafts', $this->billing, $this->preparation())->assertStatus(409);
}
public function test_future_confirmation_v2_preserves_selected_instruction_version_and_blocks_unauthorized_read(): void
{
$this->save('issuer', $this->fixture['company'], $this->profile())->assertOk();
$this->save('client', $this->client, $this->profile())->assertOk();
$payment = $this->payment(); $id = $this->save('payment', 0, $payment)->assertOk()->json('id');
$data = $this->preparation(); $data['payment_instruction_ids'] = [$id]; $this->save('billing_drafts', $this->billing, $data)->assertOk();
$this->confirm(); $before = $this->confirmation();
$this->assertSame(2, $before['snapshot']['schema_version']);
$this->assertDatabaseHas('a_events', ['id' => $before['event']['id'], 'schema_version' => 2]);
$this->assertSame($payment['account_number'], $before['snapshot']['document_preparation']['payment_instructions'][0]['data']['account_number']);
$payment['version'] = 1; $payment['instructions'] = 'Texto sintético revisto'; $this->save('payment', $id, $payment)->assertOk();
$this->assertSame($before, $this->confirmation());
$this->assertStringNotContainsString($payment['account_number'], DB::table('identity_audit')->pluck('details')->implode(''));
DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', 'issuer_documents.view')->delete();
$this->getJson("/api/a/billing_drafts/$this->billing")->assertForbidden();
}
public function test_invalid_dates_mode_and_precision_are_rejected(): void
{
$data = $this->preparation(); $data['period_end'] = '2026-10-31'; $this->save('billing_drafts', $this->billing, $data)->assertUnprocessable();
$data = $this->preparation(); $data['price_mode'] = 'tax-zero'; $this->save('billing_drafts', $this->billing, $data)->assertUnprocessable();
$this->writeA('billing_drafts', ['version' => 1, 'lines' => [['service_id' => $this->service, 'quantity' => '1', 'unit_price' => '0.123456789']]], $this->billing)->assertUnprocessable();
}
public function test_sql_mass_scope_transfer_is_blocked(): void
{
$this->save('issuer', $this->fixture['company'], $this->profile())->assertOk();
$this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_DOC_IMMUTABLE_SCOPE');
DB::table('doc_records')->update(['company_id' => $this->fixture['sibling'], 'version' => 2]);
}
public function test_history_is_append_only(): void
{
$this->save('issuer', $this->fixture['company'], $this->profile())->assertOk();
$this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_APPEND_ONLY_AUDIT');
DB::table('doc_history')->update(['snapshot' => '{}']);
}
public function test_failure_rolls_back_and_does_not_expose_instruction_values(): void
{
DB::unprepared("CREATE OR REPLACE FUNCTION sgf_doc_test_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'SYNTHETIC_DOC_FAILURE'; END; \$\$; CREATE TRIGGER synthetic_doc_fail BEFORE INSERT ON doc_history FOR EACH ROW EXECUTE FUNCTION sgf_doc_test_fail();");
$payment = $this->payment(); $response = $this->save('payment', 0, $payment)->assertStatus(500);
$this->assertStringNotContainsString($payment['account_number'], $response->getContent());
$this->assertSame(0, DB::table('doc_records')->count());
$this->assertSame(0, DB::table('doc_history')->count());
$log = storage_path('logs/laravel.log');
$this->assertFalse(is_file($log) && str_contains(file_get_contents($log), $payment['account_number']), 'Instruction values must not appear in technical logs.');
}
public function test_preparation_becomes_stale_after_a_draft_edit_and_is_not_silently_reinterpreted(): void
{
$this->save('billing_drafts', $this->billing, $this->preparation())->assertOk();
$this->writeA('billing_drafts', ['version' => 1, 'description' => 'Descrição revista'], $this->billing)->assertOk();
$record = $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk();
$this->assertSame('stale', collect($record->json('review'))->firstWhere('field', 'draft_version')['state']);
$this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()])->postJson("/api/b1/billing-drafts/$this->billing/confirm-internal", ['version' => 2])->assertStatus(409);
$data = $this->preparation(2); $data['version'] = 1; $this->save('billing_drafts', $this->billing, $data)->assertOk();
$this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->assertJsonPath('base_amount', '0.0000000125');
}
}