Files
Investidor-V2/tests/Feature/DeskRequestCheckpointTest.php
T

205 lines
14 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\VirtualDesk;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Tests\TestCase;
final class DeskRequestCheckpointTest extends TestCase
{
use RefreshDatabase;
private User $owner;
private User $manager;
private string $id;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->owner = User::factory()->create();
$this->manager = User::factory()->create();
config(['portal.managers' => [$this->manager->email]]);
$definition = config('virtual-desk.services.project-change');
$definition['fields'] = [...$definition['fields'],
'email' => ['label' => 'SYNTHETIC email', 'type' => 'email', 'required' => false],
'amount' => ['label' => 'SYNTHETIC amount', 'type' => 'number', 'required' => false],
'choices' => ['label' => 'SYNTHETIC choices', 'type' => 'multiselect', 'required' => false, 'options' => ['0' => 'Zero', 'cotton' => 'Cotton']],
'declaration' => ['label' => 'SYNTHETIC declaration', 'type' => 'consent', 'required' => false, 'statement' => 'SYNTHETIC statement, no actual authorisation.'],
];
$edition = $this->actingAs($this->manager)->putJson('/management/balcao/servicos/project-change', ['base_version' => 0, 'definition' => $definition, 'confirm' => true, 'reason' => 'SYNTHETIC checkpoint QA, not an actual publication.'])->assertCreated()->json('id');
$case = $this->actingAs($this->owner)->postJson('/balcao-virtual/dossiers', ['title' => 'SYNTHETIC checkpoint', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$this->id = $this->postJson('/balcao-virtual/dossiers/'.$case.'/pedidos', ['service_version_id' => $edition, 'client_key' => (string) Str::uuid()])->assertCreated()->json('id');
}
private function url(): string
{
return '/balcao-virtual/pedidos/'.$this->id.'/rascunho';
}
private function input(array $payload = [], int $version = 0, int $requestVersion = 1): array
{
return ['version' => $version, 'request_version' => $requestVersion, 'operation_key' => (string) Str::uuid(), 'payload' => $payload, 'edited_keys' => array_keys($payload), 'reason' => 'SYNTHETIC incomplete correction'];
}
private function answers(): array
{
return ['title' => 'SYNTHETIC main', 'description' => 'SYNTHETIC complete description', 'original_reference' => 'SYNTHETIC-REF', 'change_reason' => 'SYNTHETIC reason'];
}
public function test_incomplete_formats_are_recoverable_encrypted_and_never_change_the_authoritative_request_or_notifications(): void
{
$before = DB::table('desk_requests')->where('id', $this->id)->first();
$events = DB::table('desk_events')->count();
$notifications = DB::table('desk_notifications')->count();
$payload = ['email' => 'incomplete@', 'amount' => '-', 'choices' => ['cotton', '0'], 'declaration' => 'declined'];
$this->postJson($this->url(), $this->input($payload))->assertOk()->assertJsonPath('checkpoint.version', 1)->assertJsonPath('checkpoint.payload.email', 'incomplete@');
$this->getJson($this->url())->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('checkpoint.payload.amount', '-')->assertJsonPath('checkpoint.payload.choices', ['0', 'cotton'])->assertJsonPath('checkpoint.payload.declaration', 'declined');
$row = DB::table('desk_request_checkpoints')->first();
$this->assertStringNotContainsString('incomplete@', $row->snapshot);
$this->assertSame(app(VirtualDesk::class)->decode($before->payload), app(VirtualDesk::class)->decode($row->snapshot)['base_payload']);
$this->assertEquals($before, DB::table('desk_requests')->where('id', $this->id)->first());
$this->assertSame($events, DB::table('desk_events')->count());
$this->assertSame($notifications, DB::table('desk_notifications')->count());
$this->assertDatabaseCount('desk_revisions', 0);
$audit = DB::table('audit_events')->where('action', 'draft_checkpoint_saved')->first();
$this->assertSame(['version' => 1, 'request_version' => 1], json_decode($audit->after_data, true));
$this->putJson('/balcao-virtual/pedidos/'.$this->id, ['version' => 1, 'payload' => [...$this->answers(), ...$payload], 'submit' => true, 'consent' => true])->assertUnprocessable()->assertJsonValidationErrors(['payload.email', 'payload.amount']);
}
public function test_attempt_retry_is_idempotent_but_changed_body_reused_key_and_stale_version_conflict(): void
{
$input = $this->input(['title' => 'SYNTHETIC one']);
$this->postJson($this->url(), $input)->assertOk();
$row = DB::table('desk_request_checkpoints')->first();
$this->postJson($this->url(), $input)->assertOk()->assertJsonPath('checkpoint.version', 1);
$this->assertEquals($row, DB::table('desk_request_checkpoints')->first());
$this->postJson($this->url(), [...$input, 'payload' => ['title' => 'SYNTHETIC changed key']])->assertConflict();
$this->postJson($this->url(), $this->input(['title' => 'SYNTHETIC other tab']))->assertConflict();
$this->postJson($this->url(), $this->input(['title' => 'SYNTHETIC fresh'], 1))->assertOk()->assertJsonPath('checkpoint.version', 2);
$this->assertSame(2, DB::table('audit_events')->where('action', 'draft_checkpoint_saved')->count());
}
public function test_stale_main_version_is_exposed_for_comparison_and_cannot_be_silently_overwritten(): void
{
$this->postJson($this->url(), $this->input(['title' => 'SYNTHETIC pending']))->assertOk();
$this->putJson('/balcao-virtual/pedidos/'.$this->id, ['version' => 1, 'payload' => ['title' => 'SYNTHETIC manually changed']])->assertOk();
$this->postJson($this->url(), $this->input(['title' => 'SYNTHETIC stale'], 1))->assertConflict();
$this->getJson($this->url())->assertOk()->assertJsonPath('request.version', 2)->assertJsonPath('request.payload.title', 'SYNTHETIC manually changed')->assertJsonPath('checkpoint.request_version', 1)->assertJsonPath('checkpoint.payload.title', 'SYNTHETIC pending');
$this->postJson($this->url(), $this->input(['title' => 'SYNTHETIC deliberately recovered'], 1, 2))->assertOk()->assertJsonPath('checkpoint.base_payload.title', 'SYNTHETIC manually changed');
$this->assertSame('SYNTHETIC manually changed', app(VirtualDesk::class)->decode(DB::table('desk_requests')->where('id', $this->id)->value('payload'))['title']);
}
public function test_checkpoint_route_cannot_submit_accept_arbitrary_keys_or_forged_baseline_and_still_bounds_types(): void
{
foreach ([['submit' => true], ['consent' => true], ['base_payload' => []], ['field_declarations' => []]] as $extra) {
$this->postJson($this->url(), [...$this->input(), ...$extra])->assertUnprocessable();
}
foreach ([['unknown' => 'SYNTHETIC'], ['choices' => ['absent']], ['choices' => [0]], ['declaration' => true], ['email' => ['nested']], ['amount' => false], ['amount' => str_repeat('1', 1001)]] as $payload) {
$this->postJson($this->url(), $this->input($payload))->assertUnprocessable();
}
$this->postJson($this->url(), [...$this->input(), 'edited_keys' => ['unknown']])->assertUnprocessable();
$this->assertDatabaseCount('desk_request_checkpoints', 0);
$this->postJson($this->url(), $this->input())->assertOk()->assertJsonPath('checkpoint.payload', []);
}
public function test_server_derives_edit_intent_and_conserves_deliberate_clear_and_zero(): void
{
$this->postJson($this->url(), [...$this->input(['title' => 'SYNTHETIC changed', 'amount' => 0]), 'edited_keys' => []])->assertOk()->assertJsonPath('checkpoint.edited_keys', ['title', 'description', 'regime', 'amount']);
$this->postJson($this->url(), $this->input(['amount' => 0, 'choices' => [], 'declaration' => null], 1))->assertOk()->assertJsonPath('checkpoint.payload.amount', 0)->assertJsonPath('checkpoint.payload.choices', [])->assertJsonPath('checkpoint.payload.declaration', null);
}
public function test_manager_and_unrelated_user_cannot_read_or_write_private_recovery(): void
{
$this->postJson($this->url(), $this->input(['title' => 'SYNTHETIC private recovery']))->assertOk();
foreach ([$this->manager, User::factory()->create()] as $actor) {
$this->actingAs($actor)->getJson($this->url())->assertForbidden()->assertDontSee('SYNTHETIC private recovery');
$this->postJson($this->url(), $this->input())->assertForbidden();
}
$this->assertDatabaseCount('desk_request_checkpoints', 1);
}
public function test_submitted_and_closed_requests_reject_checkpoint_writes_and_do_not_create_a_declaration_snapshot_from_recovery(): void
{
$this->postJson($this->url(), $this->input(['declaration' => 'accepted']))->assertOk();
$this->putJson('/balcao-virtual/pedidos/'.$this->id, ['version' => 1, 'payload' => $this->answers(), 'submit' => true, 'consent' => true])->assertOk();
$row = DB::table('desk_requests')->where('id', $this->id)->first();
$this->postJson($this->url(), $this->input(['declaration' => 'accepted'], 1, 2))->assertConflict();
$this->getJson($this->url())->assertOk()->assertJsonPath('request.can_edit', false);
$this->assertSame([], app(VirtualDesk::class)->decode($row->submitted_snapshot)['field_declarations']);
$this->assertArrayNotHasKey('declaration', app(VirtualDesk::class)->decode($row->submitted_snapshot)['payload']);
$this->assertEquals($row, DB::table('desk_requests')->where('id', $this->id)->first());
}
public function test_returned_scope_is_enforced_for_both_values_and_claimed_intent(): void
{
$this->putJson('/balcao-virtual/pedidos/'.$this->id, ['version' => 1, 'payload' => $this->answers(), 'submit' => true, 'consent' => true])->assertOk();
app(VirtualDesk::class)->transition($this->manager, $this->id, ['version' => 2, 'status' => 'returned', 'reason' => 'SYNTHETIC description only', 'editable_fields' => ['description']]);
$this->postJson($this->url(), $this->input([...$this->answers(), 'description' => 'SYNTHETIC revised'], 0, 3))->assertUnprocessable();
$data = [...$this->input([...$this->answers(), 'description' => 'SYNTHETIC revised'], 0, 3), 'edited_keys' => ['description']];
$this->postJson($this->url(), $data)->assertOk()->assertJsonPath('checkpoint.edited_keys', ['description']);
$this->postJson($this->url(), [...$data, 'version' => 1, 'operation_key' => (string) Str::uuid(), 'payload' => [...$this->answers(), 'title' => 'SYNTHETIC forbidden']])->assertUnprocessable();
}
public function test_missing_migration_keeps_manual_route_available_and_rollback_refuses_retained_drafts(): void
{
Schema::rename('desk_request_checkpoints', 'desk_request_checkpoints_qa_absent');
try {
$this->getJson($this->url())->assertOk()->assertJsonPath('installed', false);
$this->postJson($this->url(), $this->input())->assertConflict();
$this->putJson('/balcao-virtual/pedidos/'.$this->id, ['version' => 1, 'payload' => []])->assertOk();
} finally {
Schema::rename('desk_request_checkpoints_qa_absent', 'desk_request_checkpoints');
}
$this->postJson($this->url(), $this->input(['title' => 'SYNTHETIC preserved'], 0, 2))->assertOk();
$migration = require database_path('migrations/2026_11_03_040000_create_desk_request_checkpoints.php');
try {
$migration->down();
$this->fail('Rollback must not remove retained drafts.');
} catch (\RuntimeException $error) {
$this->assertStringContainsString('Preserve', $error->getMessage());
}
$this->assertDatabaseCount('desk_request_checkpoints', 1);
}
public function test_organisation_recovery_is_private_per_actor_and_revoked_or_read_only_members_have_no_access(): void
{
$org = (string) Str::uuid();
$officer = User::factory()->create();
$reader = User::factory()->create();
DB::table('organizations')->insert(['id' => $org, 'name' => 'SYNTHETIC checkpoint org', 'organization_type' => 'private']);
foreach ([[$this->owner, 'admin'], [$officer, 'officer'], [$reader, 'viewer']] as [$actor,$role]) {
DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'organization_id' => $org, 'user_id' => $actor->id, 'role' => $role]);
}
$caseId = DB::table('desk_requests')->where('id', $this->id)->value('case_id');
DB::table('desk_cases')->where('id', $caseId)->update(['organization_id' => $org]);
$this->postJson($this->url(), $this->input(['title' => 'SYNTHETIC owner only']))->assertOk();
$this->actingAs($officer)->getJson($this->url())->assertOk()->assertJsonPath('checkpoint', null)->assertDontSee('SYNTHETIC owner only');
$this->postJson($this->url(), $this->input(['title' => 'SYNTHETIC officer only']))->assertOk();
$this->actingAs($this->owner)->getJson($this->url())->assertOk()->assertJsonPath('checkpoint.payload.title', 'SYNTHETIC owner only');
$this->actingAs($reader)->getJson($this->url())->assertForbidden();
DB::table('memberships')->where('organization_id', $org)->where('user_id', $officer->id)->update(['deleted_at' => now()]);
$this->actingAs($officer)->getJson($this->url())->assertForbidden()->assertDontSee('SYNTHETIC officer only');
$this->postJson($this->url(), $this->input([], 1))->assertForbidden();
$this->assertDatabaseCount('desk_request_checkpoints', 2);
}
public function test_closed_dossier_does_not_accept_checkpoint_even_when_request_is_still_a_draft(): void
{
$caseId = DB::table('desk_requests')->where('id', $this->id)->value('case_id');
DB::table('desk_cases')->where('id', $caseId)->update(['closed_at' => now()]);
$this->getJson($this->url())->assertOk()->assertJsonPath('request.can_edit', false);
$this->postJson($this->url(), $this->input(['title' => 'SYNTHETIC forbidden']))->assertConflict();
$this->putJson('/balcao-virtual/pedidos/'.$this->id, ['version' => 1, 'payload' => []])->assertConflict();
$this->assertSame(1, DB::table('desk_requests')->where('id', $this->id)->value('version'));
$this->assertDatabaseCount('desk_request_checkpoints', 0);
}
}