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 { // The applicator is `class` (not final/not readonly) specifically so // listener tests can extend and override apply(). We use the same // override mechanism here. Properties on the parent are readonly // promoted constructor params; we skip parent::__construct because // we override the ONLY method (apply) that touches them. $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); } public function test_successful_retry_creates_succeeded_attempt_and_resolves_parent(): void { $failure = $this->makeFailure(); $actor = User::factory()->create(['first_name' => 'Maud', 'last_name' => 'Admin']); // Applicator returns a real BindingPassResult that maps to COMPLETED. $this->bindApplicator(fn (): BindingPassResult => new BindingPassResult( formSubmissionId: (string) $failure->form_submission_id, provisionedSubjectType: 'person', provisionedSubjectId: (string) \Illuminate\Support\Str::ulid(), applications: [], )); $service = $this->app->make(FormFailureRetryService::class); $result = $service->retry($failure, $actor); $this->assertSame('succeeded', $result['outcome']); $failure->refresh(); $this->assertNotNull($failure->resolved_at); $this->assertSame((string) $actor->id, (string) $failure->resolved_by_user_id); $this->assertSame('Geslaagde retry door Maud Admin', $failure->resolved_note); $this->assertSame(1, $failure->retry_count); $attempts = FormSubmissionActionFailureRetryAttempt::query() ->where('form_submission_action_failure_id', $failure->id) ->get(); $this->assertCount(1, $attempts); $this->assertSame('succeeded', $attempts[0]->outcome); $this->assertNull($attempts[0]->exception_class); // Parent's original exception fields stay audit-immutable. $this->assertSame('OriginalException', $failure->exception_class); $this->assertSame('first failure message', $failure->exception_message); } public function test_failed_retry_creates_failed_attempt_and_keeps_parent_open(): void { $failure = $this->makeFailure(); $this->bindApplicator(function (): never { throw new RuntimeException('NEW exception on retry'); }); $service = $this->app->make(FormFailureRetryService::class); $result = $service->retry($failure); $this->assertSame('failed', $result['outcome']); $failure->refresh(); $this->assertNull($failure->resolved_at); $this->assertNull($failure->dismissed_at); $this->assertSame(1, $failure->retry_count); // Parent's original exception fields untouched (audit-immutable). $this->assertSame('OriginalException', $failure->exception_class); $this->assertSame('first failure message', $failure->exception_message); $attempts = FormSubmissionActionFailureRetryAttempt::query() ->where('form_submission_action_failure_id', $failure->id) ->get(); $this->assertCount(1, $attempts); $this->assertSame('failed', $attempts[0]->outcome); $this->assertSame(RuntimeException::class, $attempts[0]->exception_class); $this->assertSame('NEW exception on retry', $attempts[0]->exception_message); } public function test_retry_on_resolved_failure_throws(): void { $failure = $this->makeFailure(); $failure->update(['resolved_at' => now(), 'resolved_note' => 'manual fix']); $service = $this->app->make(FormFailureRetryService::class); $this->expectException(FailureNotRetriableException::class); $service->retry($failure); } public function test_retry_on_dismissed_failure_throws(): void { $failure = $this->makeFailure(); $failure->update([ 'dismissed_at' => now(), 'dismissed_reason_type' => 'schema_deleted', ]); $service = $this->app->make(FormFailureRetryService::class); $this->expectException(FailureNotRetriableException::class); $service->retry($failure); } public function test_multiple_retries_produce_chronologically_ordered_attempts(): void { $failure = $this->makeFailure(); $callCount = 0; $this->bindApplicator(function () use (&$callCount): never { $callCount++; throw new RuntimeException("attempt #{$callCount}"); }); $service = $this->app->make(FormFailureRetryService::class); $service->retry($failure); $failure->refresh(); $service->retry($failure); $failure->refresh(); $service->retry($failure); $failure->refresh(); $this->assertSame(3, $failure->retry_count); $this->assertNull($failure->resolved_at); $attempts = FormSubmissionActionFailureRetryAttempt::query() ->where('form_submission_action_failure_id', $failure->id) ->oldest('attempted_at') ->get(); $this->assertCount(3, $attempts); $this->assertSame('attempt #1', $attempts[0]->exception_message); $this->assertSame('attempt #2', $attempts[1]->exception_message); $this->assertSame('attempt #3', $attempts[2]->exception_message); } public function test_can_be_retried_blocks_resolved_state(): void { $failure = $this->makeFailure(); $this->assertTrue($failure->canBeRetried()); $failure->update(['resolved_at' => now()]); $this->assertFalse($failure->fresh()->canBeRetried()); } public function test_can_be_retried_blocks_dismissed_state(): void { $failure = $this->makeFailure(); $failure->update(['dismissed_at' => now(), 'dismissed_reason_type' => 'schema_deleted']); $this->assertFalse($failure->fresh()->canBeRetried()); } }