Files
Investidor-V2/tests/Feature/SimulationDraftContinuityTest.php
T
nala e65cb26901
tests / ci (push) Canceled after 0s
varias alteracoes
2026-10-06 14:50:12 +02:00

223 lines
12 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\SimulationFormDraft;
use App\Models\User;
use App\Http\Middleware\EnsureSimulationRecoveryCookie;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Tests\TestCase;
class SimulationDraftContinuityTest extends TestCase
{
use RefreshDatabase;
public function test_recovery_cookie_is_http_only_scoped_and_has_a_thirty_day_deadline(): void
{
$response = $this->getJson('/api/v1/simulation-drafts/current')->assertOk()->assertJsonPath('data', null);
$setCookieHeaders = $response->headers->all('set-cookie');
$cookieHeader = implode('; ', $setCookieHeaders);
$this->assertStringContainsString('investmoz_simulation_recovery=', $cookieHeader);
$this->assertStringContainsString('Max-Age=2592000', $cookieHeader);
$this->assertStringContainsString('path=/api/v1', $cookieHeader);
$this->assertStringContainsString('httponly', strtolower($cookieHeader));
$this->assertStringContainsString('samesite=lax', strtolower($cookieHeader));
}
public function test_automatic_reads_do_not_renew_expiry_but_an_explicit_save_does(): void
{
$token = Str::random(48);
$created = $this->postJson('/api/v1/simulation-drafts', $this->payload('Prazo de teste'), [
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token,
])->assertCreated();
$id = $created->json('data.id');
$saved = $this->putJson('/api/v1/simulation-drafts/'.$id, $this->payload('Prazo após gravação'), [
'If-Match' => $created->headers->get('ETag'), 'X-Simulation-Token' => $token,
])->assertOk();
$this->assertStringContainsString('Max-Age=2592000', implode('; ', $saved->headers->all('set-cookie')));
$initial = SimulationFormDraft::findOrFail($id);
$expiresAt = $initial->expires_at->toISOString();
$updatedAt = $initial->updated_at->toISOString();
$read = $this->getJson('/api/v1/simulation-drafts/current', ['X-Simulation-Token' => $token])->assertOk();
$read->assertJsonPath('data.expires_at', $expiresAt);
$this->assertSame($updatedAt, SimulationFormDraft::findOrFail($id)->updated_at->toISOString());
$this->travel(10)->days();
$updated = $this->putJson('/api/v1/simulation-drafts/'.$id, $this->payload('Prazo renovado'), [
'If-Match' => $saved->headers->get('ETag'), 'X-Simulation-Token' => $token,
])
->assertOk();
$this->assertEquals(now()->addDays(30)->timestamp, SimulationFormDraft::findOrFail($id)->expires_at->timestamp);
}
public function test_forget_current_soft_deletes_only_the_anonymous_draft_and_clears_cookie(): void
{
$token = Str::random(48);
$created = $this->postJson('/api/v1/simulation-drafts', $this->payload('Esquecer'), [
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token,
])->assertCreated();
$response = $this->deleteJson('/api/v1/simulation-drafts/current', [], ['X-Simulation-Token' => $token])->assertNoContent();
$this->assertDatabaseHas('simulation_form_drafts', ['id' => $created->json('data.id')]);
$this->assertNotNull(DB::table('simulation_form_drafts')->where('id', $created->json('data.id'))->value('deleted_at'));
$this->assertStringContainsString('Max-Age=0', implode('; ', $response->headers->all('set-cookie')));
}
public function test_a_routine_read_does_not_refresh_an_existing_recovery_cookie(): void
{
$request = Request::create('/api/v1/simulation-drafts/current', 'GET', cookies: [
EnsureSimulationRecoveryCookie::NAME => Str::random(48),
]);
$response = app(EnsureSimulationRecoveryCookie::class)->handle($request, fn () => response()->json(['data' => null]));
$this->assertEmpty($response->headers->all('set-cookie'));
}
public function test_explicit_continue_later_touch_renews_only_the_deadline_without_changing_snapshot_version(): void
{
$token = Str::random(48);
$created = $this->postJson('/api/v1/simulation-drafts', $this->payload('Renovar sem alterações'), [
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token,
])->assertCreated();
$this->travel(10)->days();
$response = $this->postJson('/api/v1/simulation-drafts/'.$created->json('data.id').'/touch', [], [
'If-Match' => $created->headers->get('ETag'), 'X-Simulation-Token' => $token,
])->assertOk();
$this->assertEquals(now()->addDays(30)->timestamp, SimulationFormDraft::findOrFail($created->json('data.id'))->expires_at->timestamp);
$this->assertSame(1, SimulationFormDraft::findOrFail($created->json('data.id'))->version_no);
$this->assertSame($created->headers->get('ETag'), $response->headers->get('ETag'));
}
public function test_database_details_are_not_returned_by_simulation_draft_api_errors(): void
{
Route::get('/api/v1/simulation-drafts/synthetic-failure/force', function (): never {
throw new \Illuminate\Database\QueryException('pgsql', 'select * from private_draft_table', ['private-token'], new \PDOException('private-db-name relation missing'));
});
$this->getJson('/api/v1/simulation-drafts/synthetic-failure/force')->assertStatus(500)
->assertJsonPath('message', 'Não foi possível guardar ou recuperar o rascunho. Os dados existentes não foram apagados. Tente novamente mais tarde.')
->assertDontSee('private_draft_table')->assertDontSee('private-token')->assertDontSee('private-db-name');
}
public function test_claiming_an_anonymous_draft_creates_another_private_project_without_merging_and_is_idempotent(): void
{
$token = Str::random(48);
$anonymous = $this->postJson('/api/v1/simulation-drafts', $this->payload('Ideia temporária'), [
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token,
])->assertCreated();
$actor = User::factory()->create();
$this->actingAs($actor);
$existing = $this->postJson('/api/v1/simulation-drafts', $this->payload('Projecto privado existente'), [
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => Str::random(48),
])->assertCreated();
$this->getJson('/api/v1/simulation-drafts/claim-preview', ['X-Simulation-Token' => $token])
->assertOk()->assertJsonPath('data.title', 'Ideia temporária');
$claim = fn () => $this->postJson('/api/v1/simulation-drafts/claim', ['choice' => 'save_as_new'], [
'X-Simulation-Token' => $token,
]);
$claim()->assertOk()->assertJsonPath('choice', 'save_as_new')->assertJsonPath('data.id', $anonymous->json('data.id'));
$claim()->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('data.id', $anonymous->json('data.id'));
$this->assertDatabaseHas('simulation_form_drafts', [
'id' => $anonymous->json('data.id'), 'owner_user_id' => $actor->id,
'anonymous_token_hash' => null, 'expires_at' => null,
'claimed_token_hash' => hash_hmac('sha256', $token, (string) config('app.key')),
]);
$this->getJson('/api/v1/simulation-drafts')->assertOk()->assertJsonCount(2, 'data');
$this->getJson('/api/v1/simulation-drafts/'.$existing->json('data.id'))
->assertOk()->assertJsonPath('data.economic_snapshot.project_title', 'Projecto privado existente');
$this->getJson('/api/v1/simulation-drafts/'.$anonymous->json('data.id'))
->assertOk()->assertJsonPath('data.economic_snapshot.project_title', 'Ideia temporária');
}
public function test_continue_existing_choice_leaves_the_anonymous_draft_unclaimed(): void
{
$token = Str::random(48);
$anonymous = $this->postJson('/api/v1/simulation-drafts', $this->payload('Não fundir'), [
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token,
])->assertCreated();
$actor = User::factory()->create();
$this->actingAs($actor);
$existing = $this->postJson('/api/v1/simulation-drafts', $this->payload('Manter existente'), [
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => Str::random(48),
])->assertCreated();
$this->postJson('/api/v1/simulation-drafts/claim', [
'choice' => 'continue_existing', 'existing_draft_id' => $existing->json('data.id'),
], ['X-Simulation-Token' => $token])->assertOk()->assertJsonPath('data.id', $existing->json('data.id'));
$this->assertDatabaseHas('simulation_form_drafts', [
'id' => $anonymous->json('data.id'), 'owner_user_id' => null,
'anonymous_token_hash' => hash_hmac('sha256', $token, (string) config('app.key')),
]);
$this->assertDatabaseCount('simulation_form_drafts', 2);
}
public function test_expired_anonymous_draft_is_not_readable_and_same_recovery_cookie_can_start_a_new_draft(): void
{
$token = Str::random(48);
$first = $this->postJson('/api/v1/simulation-drafts', $this->payload('Expira'), [
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token,
])->assertCreated();
$this->travel(31)->days();
$this->getJson('/api/v1/simulation-drafts/current', ['X-Simulation-Token' => $token])
->assertOk()->assertJsonPath('data', null);
$second = $this->postJson('/api/v1/simulation-drafts', $this->payload('Novo prazo'), [
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token,
])->assertCreated();
$this->assertNotSame($first->json('data.id'), $second->json('data.id'));
$this->assertDatabaseHas('simulation_form_drafts', ['id' => $first->json('data.id'), 'deleted_at' => now()]);
$this->assertGreaterThan(now(), SimulationFormDraft::findOrFail($second->json('data.id'))->expires_at);
}
public function test_business_association_is_explicit_reversible_and_does_not_copy_or_publish_data(): void
{
config(['existing-business.enabled' => true]);
$actor = User::factory()->create();
$this->actingAs($actor);
$business = $this->postJson('/api/v1/existing-business/listings', [
'modality' => 'company_sale', 'classification' => 'confidential', 'reason' => 'Create synthetic private test business',
'payload' => ['title' => 'Negócio de teste', 'wizard_version' => '1', 'wizard_step' => '1'],
])->assertCreated();
$draft = $this->postJson('/api/v1/simulation-drafts', $this->payload('Plano separado'), [
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => Str::random(48),
])->assertCreated();
$id = $draft->json('data.id');
$this->getJson('/api/v1/simulation-drafts/linkable-businesses')->assertOk()
->assertJsonFragment(['id' => $business->json('id'), 'title' => 'Negócio de teste']);
$this->postJson('/api/v1/simulation-drafts/'.$id.'/business-link', ['business_id' => $business->json('id')])
->assertCreated();
$this->getJson('/api/v1/simulation-drafts/'.$id.'/business-link')->assertOk()
->assertJsonPath('data.business_id', $business->json('id'))
->assertJsonPath('data.title', 'Negócio de teste');
$this->deleteJson('/api/v1/simulation-drafts/'.$id.'/business-link')->assertOk()->assertJsonPath('data.unlinked', true);
$this->assertDatabaseHas('simulation_draft_business_links', ['draft_id' => $id, 'business_id' => $business->json('id')]);
$this->assertNotNull(DB::table('simulation_draft_business_links')->where('draft_id', $id)->value('unlinked_at'));
$this->assertDatabaseHas('existing_businesses', ['id' => $business->json('id'), 'status' => 'draft']);
$this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()
->assertJsonPath('data.economic_snapshot.project_title', 'Plano separado');
}
/** @return array{personal_snapshot:array<string,string>,economic_snapshot:array<string,string>} */
private function payload(string $title): array
{
return ['personal_snapshot' => [], 'economic_snapshot' => [
'journey_type' => 'small_business', 'detail_level' => 'simple', 'purpose' => 'simulate_only',
'project_title' => $title, 'tax_calculation' => 'no',
]];
}
}