app->make(FormBindingApplicator::class); $clone = $applicator->withDeadline(5); $this->assertInstanceOf(FormBindingApplicator::class, $clone); $this->assertNotSame($applicator, $clone, 'withDeadline must return a clone, not mutate the receiver'); } public function test_apply_without_deadline_does_not_throw_for_fast_runs(): void { $submission = $this->makeAnonymousSubmission(); $applicator = $this->app->make(FormBindingApplicator::class); DB::transaction(function () use ($applicator, $submission): void { $result = $applicator->apply($submission); $this->assertInstanceOf(BindingPassResult::class, $result); }); } public function test_apply_with_generous_deadline_does_not_throw(): void { $submission = $this->makeAnonymousSubmission(); $applicator = $this->app->make(FormBindingApplicator::class); DB::transaction(function () use ($applicator, $submission): void { $result = $applicator->withDeadline(60)->apply($submission); $this->assertInstanceOf(BindingPassResult::class, $result); }); } public function test_apply_throws_timeout_when_elapsed_exceeds_deadline(): void { $submission = $this->makeAnonymousSubmission(); $applicator = new SlowApplicator( $this->app->make(\App\FormBuilder\Purposes\PurposeRegistry::class), $this->app->make(\App\FormBuilder\Bindings\BindingConflictResolver::class), $this->app->make(\App\FormBuilder\Bindings\BindingTypeRegistry::class), $this->app->make(\App\FormBuilder\Bindings\BindingActivityLogger::class), ); $applicator->sleepMicroseconds = 50_000; // 50ms $this->expectException(FormBindingApplicatorTimeoutException::class); $this->expectExceptionMessageMatches('/exceeded deadline of 0s/'); DB::transaction(function () use ($applicator, $submission): void { // 0 seconds — any wall-clock elapsed should exceed it. $applicator->withDeadline(0)->apply($submission); }); } public function test_timeout_exception_carries_submission_id_and_reason_code(): void { $submission = $this->makeAnonymousSubmission(); $applicator = new SlowApplicator( $this->app->make(\App\FormBuilder\Purposes\PurposeRegistry::class), $this->app->make(\App\FormBuilder\Bindings\BindingConflictResolver::class), $this->app->make(\App\FormBuilder\Bindings\BindingTypeRegistry::class), $this->app->make(\App\FormBuilder\Bindings\BindingActivityLogger::class), ); $applicator->sleepMicroseconds = 50_000; $caught = null; try { DB::transaction(function () use ($applicator, $submission): void { $applicator->withDeadline(0)->apply($submission); }); } catch (FormBindingApplicatorTimeoutException $e) { $caught = $e; } $this->assertNotNull($caught); $this->assertSame((string) $submission->id, $caught->submissionId); // Inherited via FormBindingInfraException — Timeout extends Infra. $this->assertSame('temporary_error', $caught->reasonCode()); } /** * Anonymous incident-report submission: the applicator's * IncidentReportSubjectResolver returns null when submitted_by_user_id * is null, and the applicator's anonymous-allowed branch returns an * empty BindingPassResult. The deadline check still fires at the end. */ private function makeAnonymousSubmission(): FormSubmission { $schema = FormSchema::factory()->create([ 'purpose' => FormPurpose::INCIDENT_REPORT->value, ]); $submission = FormSubmission::factory()->create([ 'form_schema_id' => $schema->id, 'subject_type' => null, 'subject_id' => null, 'submitted_by_user_id' => null, ]); return $submission->fresh(); } } /** * Test double — sleeps inside apply() to force the deadline check at * the end of apply() to fail for any reasonable deadline value (incl. 0). */ final class SlowApplicator extends FormBindingApplicator { public int $sleepMicroseconds = 0; public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult { usleep($this->sleepMicroseconds); return parent::apply($submission, $sectionId); } }