create(); $schema = FormSchema::factory()->create([ 'organisation_id' => $org->id, 'purpose' => FormPurpose::EVENT_REGISTRATION->value, ]); $submission = FormSubmission::factory()->create([ 'form_schema_id' => $schema->id, 'organisation_id' => $org->id, ]); return FormSubmissionActionFailure::factory() ->for($submission, 'submission') ->create([ 'exception_class' => 'OriginalException', 'exception_message' => 'first failure message', ]); } /** * @param callable(FormSubmission, ?string): BindingPassResult $apply */ private function bindApplicator(callable $apply): void { $stub = new class($apply) extends FormBindingApplicator { /** @var callable(FormSubmission, ?string): BindingPassResult */ private $apply; /** @param callable(FormSubmission, ?string): BindingPassResult $apply */ public function __construct(callable $apply) { $this->apply = $apply; } public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult { return ($this->apply)($submission, $sectionId); } }; $this->app->instance(FormBindingApplicator::class, $stub); } private function retryWithThrow(callable $throwFn): FormSubmissionActionFailure { $failure = $this->makeFailure(); $this->bindApplicator(fn () => $throwFn()); $service = $this->app->make(FormFailureRetryService::class); $result = $service->retry($failure); $this->assertSame('failed', $result['outcome']); return $failure->fresh(); } public function test_record_failure_writes_failure_response_code_for_schema_config(): void { $failure = $this->retryWithThrow( fn () => throw new FormBindingSchemaConfigException(submissionId: 'x', message: 'cfg'), ); $submission = FormSubmission::query()->withoutGlobalScopes()->find($failure->form_submission_id); $this->assertSame('schema_config_error', $submission->failure_response_code); $this->assertSame(ApplyStatus::FAILED, $submission->apply_status); } public function test_record_failure_writes_failure_response_code_for_infra(): void { $failure = $this->retryWithThrow( fn () => throw new FormBindingInfraException(submissionId: 'x', message: 'infra'), ); $submission = FormSubmission::query()->withoutGlobalScopes()->find($failure->form_submission_id); $this->assertSame('temporary_error', $submission->failure_response_code); } public function test_record_failure_writes_failure_response_code_for_timeout(): void { // Timeout extends Infra → temporary_error inherited. $failure = $this->retryWithThrow( fn () => throw new FormBindingApplicatorTimeoutException(submissionId: 'x', message: 'deadline'), ); $submission = FormSubmission::query()->withoutGlobalScopes()->find($failure->form_submission_id); $this->assertSame('temporary_error', $submission->failure_response_code); } public function test_record_failure_writes_failure_response_code_for_data_integrity(): void { $failure = $this->retryWithThrow( fn () => throw new FormBindingDataIntegrityException(submissionId: 'x', message: 'fk'), ); $submission = FormSubmission::query()->withoutGlobalScopes()->find($failure->form_submission_id); $this->assertSame('data_integrity_error', $submission->failure_response_code); } public function test_record_failure_writes_unknown_error_for_arbitrary_throwable(): void { $failure = $this->retryWithThrow(fn () => throw new RuntimeException('arbitrary')); $submission = FormSubmission::query()->withoutGlobalScopes()->find($failure->form_submission_id); $this->assertSame('unknown_error', $submission->failure_response_code); } public function test_record_failure_writes_apply_completed_at_symmetry_fix(): void { // Per ARCH-BINDINGS §7.1 v1.2 retry-service asymmetry note — // pre-D2, recordFailure did NOT write apply_completed_at; only // recordSuccess did. D2 closes the asymmetry. $failure = $this->retryWithThrow( fn () => throw new FormBindingInfraException(submissionId: 'x', message: 'transient'), ); $submission = FormSubmission::query()->withoutGlobalScopes()->find($failure->form_submission_id); $this->assertNotNull( $submission->apply_completed_at, 'apply_completed_at must be set on the failure path; pre-D2 only the success path wrote it.', ); } }