seed(RoleSeeder::class); $this->orgA = Organisation::factory()->create(['name' => 'Festival X']); $this->schemaA = FormSchema::factory()->create([ 'organisation_id' => $this->orgA->id, 'name' => 'Vrijwilligers aanmelding', ]); $this->submissionA = FormSubmission::factory()->create([ 'form_schema_id' => $this->schemaA->id, 'organisation_id' => $this->orgA->id, ]); $this->superAdmin = User::factory()->create(); $this->superAdmin->assignRole('super_admin'); } public function test_show_payload_includes_denormalized_labels_and_trace(): void { $resolver = User::factory()->create(['first_name' => 'Maud', 'last_name' => 'Admin']); $failure = FormSubmissionActionFailure::factory() ->for($this->submissionA, 'submission') ->create([ 'exception_trace' => "#0 stack frame\n#1 next frame", 'resolved_at' => now(), 'resolved_by_user_id' => $resolver->id, 'resolved_note' => 'fixed manually', ]); Sanctum::actingAs($this->superAdmin); $response = $this->getJson("/api/v1/admin/form-failures/{$failure->id}")->assertOk(); $response ->assertJsonPath('data.id', (string) $failure->id) ->assertJsonPath('data.organisation_id', (string) $this->orgA->id) ->assertJsonPath('data.organisation_name', 'Festival X') ->assertJsonPath('data.form_schema_id', (string) $this->schemaA->id) ->assertJsonPath('data.form_schema_label', 'Vrijwilligers aanmelding') ->assertJsonPath('data.exception_trace', "#0 stack frame\n#1 next frame") ->assertJsonPath('data.resolved_by_user_name', 'Maud Admin') ->assertJsonPath('data.dismissed_by_user_name', null); } public function test_show_payload_includes_retry_history_in_chronological_order(): void { $failure = FormSubmissionActionFailure::factory() ->for($this->submissionA, 'submission') ->create(); $actor = User::factory()->create(['first_name' => 'Alex', 'last_name' => 'Operator']); FormSubmissionActionFailureRetryAttempt::factory()->create([ 'form_submission_action_failure_id' => $failure->id, 'attempted_at' => now()->subMinutes(10), 'attempted_by_user_id' => $actor->id, 'outcome' => 'failed', 'exception_class' => \RuntimeException::class, 'exception_message' => 'first retry failed', ]); FormSubmissionActionFailureRetryAttempt::factory()->succeeded()->create([ 'form_submission_action_failure_id' => $failure->id, 'attempted_at' => now(), 'attempted_by_user_id' => $actor->id, ]); Sanctum::actingAs($this->superAdmin); $response = $this->getJson("/api/v1/admin/form-failures/{$failure->id}")->assertOk(); $history = $response->json('data.retry_history'); $this->assertIsArray($history); $this->assertCount(2, $history); // latest('attempted_at') in the relation puts the newest first. $this->assertSame('succeeded', $history[0]['outcome']); $this->assertSame('failed', $history[1]['outcome']); $this->assertSame('Alex Operator', $history[0]['attempted_by_user_name']); $this->assertSame('first retry failed', $history[1]['exception_message']); } public function test_index_payload_omits_retry_history_to_keep_payload_small(): void { $failure = FormSubmissionActionFailure::factory() ->for($this->submissionA, 'submission') ->create(); FormSubmissionActionFailureRetryAttempt::factory()->create([ 'form_submission_action_failure_id' => $failure->id, ]); Sanctum::actingAs($this->superAdmin); $response = $this->getJson('/api/v1/admin/form-failures')->assertOk(); // whenLoaded() => the key resolves to MissingValue and is omitted. $first = $response->json('data.0'); $this->assertArrayNotHasKey('retry_history', $first); $this->assertSame('Festival X', $first['organisation_name']); $this->assertSame('Vrijwilligers aanmelding', $first['form_schema_label']); } public function test_index_does_not_n_plus_one_on_relations(): void { for ($i = 0; $i < 5; $i++) { $sub = FormSubmission::factory()->create([ 'form_schema_id' => $this->schemaA->id, 'organisation_id' => $this->orgA->id, ]); FormSubmissionActionFailure::factory()->for($sub, 'submission')->create(); } Sanctum::actingAs($this->superAdmin); DB::enableQueryLog(); $this->getJson('/api/v1/admin/form-failures')->assertOk(); $queries = DB::getQueryLog(); DB::disableQueryLog(); // Eager-loading bound: failures + submissions + organisations + schemas // + resolvedBy + dismissedBy + count = 7 baseline; allow modest headroom // for auth/sanctum/pagination overhead but reject linear growth in N. $this->assertLessThanOrEqual(15, count($queries), sprintf( 'Index endpoint may have N+1 (executed %d queries for 5 failures): %s', count($queries), implode("\n", array_column($queries, 'query')), )); } }