172 lines
11 KiB
PHP
172 lines
11 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Modules\Services\InternalConfirmation;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Tests\Support\InternalConfirmationFixture;
|
|
use Tests\Support\IdentityFixture;
|
|
use Tests\TestCase;
|
|
|
|
final class InternalConfirmationTest 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 = 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()); }
|
|
}
|
|
}
|