210 lines
13 KiB
PHP
210 lines
13 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Modules\Services\ServicePermissions;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Tests\Support\ServicesFixture;
|
|
use Tests\Support\IdentityFixture;
|
|
use Tests\TestCase;
|
|
|
|
final class ServicesDeliveryATest extends TestCase
|
|
{
|
|
use \Tests\Support\UsesModuleFixture;
|
|
protected function moduleFixture(): string { return 'commercial'; }
|
|
use RefreshDatabase;
|
|
private array $fixture;
|
|
private string $token;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp(); $this->fixture = ServicesFixture::create(); $this->login();
|
|
}
|
|
|
|
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 $data, ?int $id = null, ?string $key = null)
|
|
{
|
|
$this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => $key ?? (string) \Illuminate\Support\Str::uuid()]);
|
|
return $id ? $this->patchJson("/api/a/$area/$id", $data) : $this->postJson("/api/a/$area", $data);
|
|
}
|
|
private function prepared(): array
|
|
{
|
|
$client = $this->write('clients', ['name' => 'Cliente sintético'])->assertCreated()->json('id');
|
|
$service = $this->write('services', ['name' => 'Serviço sintético'])->assertCreated()->json('id');
|
|
$payload = ['client_id' => $client, 'lines' => [['service_id' => $service, 'quantity' => '1.25', 'unit_price' => '0.00000001']]];
|
|
return [$client, $service, $payload];
|
|
}
|
|
|
|
public function test_complete_a_workflow_reuses_source_with_exact_values_pending_and_durable_history(): void
|
|
{
|
|
[$client, $service, $payload] = $this->prepared();
|
|
$draft = $this->write('service_drafts', $payload)->assertCreated()->json('id');
|
|
$billing = $this->write('billing_drafts', ['source_id' => $draft])->assertCreated()->json('id');
|
|
$record = $this->getJson("/api/a/billing_drafts/$billing")->assertOk();
|
|
$record->assertJsonPath('client_id', $client)->assertJsonPath('source_id', $draft)->assertJsonPath('data.source_version', 1)
|
|
->assertJsonPath('data.lines.0.service_id', $service)->assertJsonPath('base_amount', '0.0000000125')
|
|
->assertJsonPath('data.currency', null)->assertJsonPath('state', 'draft')->assertJsonPath('data.tax_status', 'pending');
|
|
$this->assertArrayHasKey('currency_missing', $record->json('pending'));
|
|
$this->assertArrayHasKey('tax_rule_pending', $record->json('pending'));
|
|
$this->assertSame(4, DB::table('a_history')->count());
|
|
$this->assertSame(4, DB::table('a_events')->count());
|
|
$this->assertSame(4, DB::table('a_outbox')->where('state', 'captured')->count());
|
|
$this->assertSame(1, DB::table('a_clients')->count());
|
|
$this->assertSame(1, DB::table('a_services')->count());
|
|
$this->assertFalse(\Illuminate\Support\Facades\Schema::hasTable('journal_entries'));
|
|
}
|
|
|
|
public function test_catalogues_are_shared_between_authorized_units_but_drafts_are_not(): void
|
|
{
|
|
[, , $payload] = $this->prepared();
|
|
$first = $this->write('service_drafts', $payload)->assertCreated()->json('id');
|
|
IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], 'unit.view', $this->fixture['hidden']);
|
|
foreach (['service_drafts','billing_drafts'] as $area) { foreach (ServicePermissions::ACTIONS as $action) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], "$area.$action", $this->fixture['hidden']); } }
|
|
$this->select($this->fixture['hidden']);
|
|
$this->assertCount(1, $this->getJson('/api/a/clients')->assertOk()->json('records'));
|
|
$this->assertCount(1, $this->getJson('/api/a/services')->assertOk()->json('records'));
|
|
$this->getJson("/api/a/service_drafts/$first")->assertForbidden();
|
|
$this->write('billing_drafts', ['source_id' => $first])->assertForbidden();
|
|
$this->write('service_drafts', $payload)->assertCreated();
|
|
$this->assertSame(1, DB::table('a_clients')->count());
|
|
$this->assertSame(2, DB::table('a_service_drafts')->count());
|
|
}
|
|
|
|
public function test_company_catalogue_is_available_without_selected_unit(): void
|
|
{
|
|
$this->select(null);
|
|
$this->write('clients', ['name' => 'Cliente sem unidade'])->assertCreated();
|
|
$this->getJson('/api/a/clients')->assertOk();
|
|
$this->getJson('/api/a/service_drafts')->assertForbidden();
|
|
}
|
|
|
|
public function test_reader_can_consult_all_four_areas_but_cannot_write_or_manage(): void
|
|
{
|
|
[, , $payload] = $this->prepared();
|
|
$draft = $this->write('service_drafts', $payload)->assertCreated()->json('id');
|
|
$this->write('billing_drafts', ['source_id' => $draft])->assertCreated();
|
|
$this->login('reader');
|
|
foreach (ServicePermissions::AREAS as $area) {
|
|
$this->getJson("/api/a/$area")->assertOk();
|
|
$this->getJson("/api/a/$area/access")->assertForbidden();
|
|
$this->write($area, $area === 'clients' || $area === 'services' ? ['name' => 'Denied'] : ($area === 'service_drafts' ? $payload : ['source_id' => $draft]))->assertForbidden();
|
|
}
|
|
$client = DB::table('a_clients')->value('id');
|
|
$this->write('clients', ['name' => 'Denied', 'version' => 1], $client)->assertForbidden();
|
|
}
|
|
|
|
public function test_idempotency_replays_once_and_conflicting_content_is_refused(): void
|
|
{
|
|
$key = (string) \Illuminate\Support\Str::uuid();
|
|
$first = $this->write('clients', ['name' => 'Único'], key: $key)->assertCreated()->json();
|
|
$this->write('clients', ['name' => 'Único'], key: $key)->assertCreated()->assertJsonPath('id', $first['id'])->assertJsonPath('replayed', true);
|
|
$this->write('clients', ['name' => 'Outro'], key: $key)->assertStatus(409);
|
|
$this->assertSame(1, DB::table('a_clients')->count());
|
|
$this->assertSame(1, DB::table('a_events')->count());
|
|
$this->assertSame(1, DB::table('a_commands')->count());
|
|
}
|
|
|
|
public function test_stale_version_and_context_are_refused(): void
|
|
{
|
|
$id = $this->write('clients', ['name' => 'Inicial'])->assertCreated()->json('id');
|
|
$this->write('clients', ['name' => 'Nova versão', 'version' => 1], $id)->assertOk();
|
|
$this->write('clients', ['name' => 'Antigo', 'version' => 1], $id)->assertStatus(409);
|
|
$old = $this->token; $this->select(null);
|
|
$this->withHeaders(['X-SGF-Context' => $old, 'Idempotency-Key' => (string) \Illuminate\Support\Str::uuid()])
|
|
->patchJson("/api/a/clients/$id", ['name' => 'Contexto antigo', 'version' => 2])->assertStatus(409);
|
|
}
|
|
|
|
public function test_foreign_client_and_service_and_scope_payload_are_refused(): void
|
|
{
|
|
[, , $payload] = $this->prepared();
|
|
$foreign = DB::table('a_clients')->insertGetId(['tenant_id' => $this->fixture['otherTenant'], 'company_id' => $this->fixture['foreign'], 'unit_id' => null, 'name' => 'Outro cliente', 'data' => '{}', 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
|
$this->write('service_drafts', array_replace($payload, ['client_id' => $foreign]))->assertForbidden();
|
|
$foreignService = DB::table('a_services')->insertGetId(['tenant_id' => $this->fixture['otherTenant'], 'company_id' => $this->fixture['foreign'], 'unit_id' => null, 'name' => 'Outro serviço', 'data' => '{}', 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
|
$this->write('service_drafts', ['client_id' => $payload['client_id'], 'lines' => [['service_id' => $foreignService]]])->assertForbidden();
|
|
$this->write('clients', ['name' => 'Manipulado', 'unit_id' => $this->fixture['hidden']])->assertUnprocessable();
|
|
}
|
|
|
|
public function test_precision_is_rejected_without_rounding_and_missing_values_are_not_zero(): void
|
|
{
|
|
[, , $payload] = $this->prepared();
|
|
foreach (['0.123456789', '10000000000000000', '1e3'] as $invalid) {
|
|
$bad = $payload; $bad['lines'][0]['unit_price'] = $invalid;
|
|
$this->write('service_drafts', $bad)->assertUnprocessable();
|
|
}
|
|
$payload['lines'][0]['quantity'] = null; $payload['lines'][0]['unit_price'] = null;
|
|
$id = $this->write('service_drafts', $payload)->assertCreated()->json('id');
|
|
$result = $this->getJson("/api/a/service_drafts/$id")->assertOk();
|
|
$result->assertJsonPath('base_amount', null)->assertJsonPath('data.lines.0.unit_price', null)->assertJsonPath('data.currency', null);
|
|
$this->assertArrayHasKey('quantity_missing_0', $result->json('pending'));
|
|
$this->assertArrayHasKey('price_missing_0', $result->json('pending'));
|
|
}
|
|
|
|
public function test_source_changes_preserve_billing_snapshot_and_create_pending_until_explicit_reload(): void
|
|
{
|
|
[, , $payload] = $this->prepared();
|
|
$draft = $this->write('service_drafts', $payload)->assertCreated()->json('id');
|
|
$billing = $this->write('billing_drafts', ['source_id' => $draft])->assertCreated()->json('id');
|
|
$changed = $payload; $changed['version'] = 1; $changed['lines'][0]['quantity'] = '2';
|
|
$this->write('service_drafts', $changed, $draft)->assertOk();
|
|
$old = $this->getJson("/api/a/billing_drafts/$billing")->assertOk();
|
|
$old->assertJsonPath('data.lines.0.quantity', '1.25')->assertJsonPath('version', 1);
|
|
$this->assertArrayHasKey('source_changed', $old->json('pending'));
|
|
$this->write('billing_drafts', ['version' => 1, 'reload_source' => true], $billing)->assertOk();
|
|
$this->getJson("/api/a/billing_drafts/$billing")->assertOk()->assertJsonPath('data.lines.0.quantity', '2')->assertJsonPath('data.source_version', 2)->assertJsonCount(2, 'history');
|
|
}
|
|
|
|
public function test_transaction_failure_rolls_back_record_history_event_pending_and_command(): void
|
|
{
|
|
DB::unprepared("CREATE OR REPLACE FUNCTION sgf_test_fail_outbox() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'SYNTHETIC_OUTBOX_FAILURE'; END; \$\$; CREATE TRIGGER synthetic_failure BEFORE INSERT ON a_outbox FOR EACH ROW EXECUTE FUNCTION sgf_test_fail_outbox();");
|
|
$this->write('clients', ['name' => 'Rollback'])->assertStatus(500);
|
|
foreach (['a_clients','a_history','a_events','a_outbox','a_pending','a_commands'] as $table) { $this->assertSame(0, DB::table($table)->count()); }
|
|
}
|
|
|
|
public function test_limited_manager_cannot_grant_above_authority_and_revocation_is_immediate(): void
|
|
{
|
|
$client = $this->write('clients', ['name' => 'Consulta'])->assertCreated()->json('id');
|
|
$this->login('limited');
|
|
$this->withHeader('X-SGF-Context', $this->token)->postJson('/api/a/clients/access', ['email' => $this->fixture['reader']->email, 'action' => 'edit', 'grant' => true, 'reason' => 'Teste'])->assertForbidden();
|
|
$this->withHeader('X-SGF-Context', $this->token)->postJson('/api/a/clients/access', ['email' => $this->fixture['reader']->email, 'action' => 'view', 'grant' => false, 'reason' => 'Teste'])->assertOk();
|
|
$this->login('reader');
|
|
$this->getJson("/api/a/clients/$client")->assertForbidden();
|
|
$this->getJson('/api/a/services')->assertOk();
|
|
}
|
|
|
|
public function test_database_blocks_mass_scope_transfer_and_history_changes(): void
|
|
{
|
|
$id = $this->write('clients', ['name' => 'Protegido'])->assertCreated()->json('id');
|
|
$this->expectException(\Illuminate\Database\QueryException::class);
|
|
$this->expectExceptionMessage('SGF_A_IMMUTABLE_SCOPE');
|
|
DB::table('a_clients')->where('id', $id)->update(['company_id' => $this->fixture['sibling'], 'version' => 2]);
|
|
}
|
|
|
|
public function test_database_rejects_excess_precision_even_when_bypassing_the_http_validator(): void
|
|
{
|
|
[, , $payload] = $this->prepared();
|
|
$id = $this->write('service_drafts', $payload)->assertCreated()->json('id');
|
|
$data = json_decode(DB::table('a_service_drafts')->where('id', $id)->value('data'), true);
|
|
$data['lines'][0]['unit_price'] = '0.123456789';
|
|
$this->expectException(\Illuminate\Database\QueryException::class);
|
|
$this->expectExceptionMessage('SGF_A_DECIMAL_PRECISION');
|
|
DB::table('a_service_drafts')->where('id', $id)->update(['data' => json_encode($data), 'version' => 2]);
|
|
}
|
|
|
|
public function test_history_cannot_be_rewritten_in_bulk(): void
|
|
{
|
|
$this->write('clients', ['name' => 'Histórico preservado'])->assertCreated();
|
|
$this->expectException(\Illuminate\Database\QueryException::class);
|
|
$this->expectExceptionMessage('SGF_APPEND_ONLY_AUDIT');
|
|
DB::table('a_history')->update(['snapshot' => '{}']);
|
|
}
|
|
}
|