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,economic_snapshot:array} */ 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', ]]; } }