tenant = Tenant::create([ 'slug' => 'wm', 'name' => 'Wilde Möhre', 'address_1' => 'Musterweg 1', 'email' => 't@example.com', 'email_finance' => 'finance@example.com', // Muss dem Host der Testanfragen entsprechen, sonst weist IdentifyTenant sie mit 404 ab. 'url' => parse_url(config('app.url'), PHP_URL_HOST), 'account_name' => 'Test e.V.', 'account_iban' => 'DE00', 'account_bic' => 'XY', 'city' => 'Stadt', 'postcode' => '00000', 'invoice_prefix' => 'WM', 'is_active_local_group' => true, 'has_active_instance' => true, ]); app()->instance('tenant', $this->tenant); DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']); DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']); PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]); PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]); EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']); foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) { UserRole::create(["slug" => $role, "name" => $role]); } $this->seedTemplate(); Mail::fake(); } /** Minimale Vorlage: alles, was der Beleg braucht, in wenigen Blöcken. */ private function seedTemplate(): void { DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, 'block' => DocumentTemplate::BLOCK_LAYOUT, 'content' => '
{block:subject}{block:body}
', 'sort_order' => 10, ]); DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, 'block' => DocumentTemplate::BLOCK_STYLE, 'content' => 'body { font-size: 10pt; }', 'sort_order' => 20, ]); DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, 'block' => 'subject', 'content' => '

Rückerstattung {document_number}

{document_date} / {event_name}

', 'sort_order' => 30, ]); DocumentTemplate::create([ 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, 'block' => DocumentTemplate::BLOCK_BODY, 'content' => '
{recipient_name}
{details_table}', 'sort_order' => 40, ]); } private function makeEvent(array $attributes = []): Event { $fee = EventParticipationFee::create([ 'tenant' => $this->tenant->slug, 'type' => 'participant', 'name' => 'Sippe', 'description' => null, 'amount_standard' => 60.0, 'amount_reduced' => null, 'amount_solidarity' => null, ]); return Event::create(array_merge([ 'tenant' => $this->tenant->slug, 'name' => 'Sommerlager', 'identifier' => 'evt-' . uniqid(), 'location' => 'Ort', 'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-07-16', 'end_date' => '2026-07-20', 'early_bird_end' => '2026-06-20', 'registration_final_end' => '2026-07-01', 'early_bird_end_amount_increase' => 0, 'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'fixed', 'participation_fee_1' => $fee->id, 'pay_per_day' => true, 'pay_direct' => false, 'tax_liable' => false, 'vat_rate' => 0, 'vat_pricing_mode' => 'inclusive', 'invoice_key' => 'WM-V-20260701', ], $attributes)); } /** Standardfall dieser Tests: abgemeldet und vollständig bezahlt. */ private function makeParticipant(Event $event, array $attributes = []): EventParticipant { $this->sequence++; return $event->participants()->create(array_merge([ 'tenant' => $this->tenant->slug, 'identifier' => 'p-' . uniqid(), 'invoice_sequence' => $this->sequence, 'firstname' => 'Mika', 'lastname' => 'Muster', 'participation_type' => 'participant', 'fee_type' => 'standard', 'sibling_reduction' => false, 'local_group' => $this->tenant->slug, 'birthday' => '2000-01-01', 'address_1' => 'Beispielstraße 3', 'postcode' => '11111', 'city' => 'Beispielstadt', 'email_1' => 'mika@example.com', 'phone_1' => '0170 0000000', 'arrival_date' => '2026-07-16', 'departure_date' => '2026-07-20', 'arrival_eating' => 1, 'departure_eating' => 1, 'amount' => 300.0, 'amount_paid' => 300.0, 'unregistered_at' => '2026-06-12', 'payment_purpose' => 'Sommerlager', 'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, 'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, ], $attributes)); } private function release( EventParticipant $participant, float $amount = 300.0, string $reason = RefundReason::SICKNESS, ?string $note = null, ) { return new ReleaseRefundCommand(new ReleaseRefundRequest( participant: $participant, amount: new Amount($amount, 'Euro'), reason: $reason, reasonNote: $note, ))->execute(); } private function accept( ?ParticipantRefund $refund, string $owner = 'Mika Muster', string $iban = 'DE02120300000000202051', bool $declarationAccepted = true, ) { return new AcceptRefundCommand(new AcceptRefundRequest( refund: $refund, accountOwner: $owner, accountIban: $iban, declarationAccepted: $declarationAccepted, ))->execute(); } /* |-------------------------------------------------------------------------- | Freigabe |-------------------------------------------------------------------------- */ public function test_release_creates_a_pending_refund_with_a_token(): void { $participant = $this->makeParticipant($this->makeEvent()); $response = $this->release($participant); $this->assertTrue($response->success); $this->assertSame(ParticipantRefund::STATUS_PENDING, $response->refund->status); $this->assertSame(32, strlen($response->refund->token)); $this->assertEqualsWithDelta(300.0, $response->refund->amount->getAmount(), 0.001); $this->assertNotNull($response->refund->released_at); } public function test_release_notifies_participant_and_contact_person(): void { $participant = $this->makeParticipant($this->makeEvent(), ['email_2' => 'eltern@example.com']); $this->release($participant); Mail::assertSent(RefundReleasedMail::class, 2); Mail::assertSent(RefundReleasedMail::class, fn ($mail) => $mail->hasTo('mika@example.com')); Mail::assertSent(RefundReleasedMail::class, fn ($mail) => $mail->hasTo('eltern@example.com')); } public function test_release_sends_only_one_mail_without_contact_person(): void { $this->release($this->makeParticipant($this->makeEvent())); Mail::assertSent(RefundReleasedMail::class, 1); } public function test_release_is_rejected_for_a_registered_participant(): void { $participant = $this->makeParticipant($this->makeEvent(), ['unregistered_at' => null]); $response = $this->release($participant); $this->assertFalse($response->success); $this->assertStringContainsString('abgemeldete', $response->message); $this->assertSame(0, ParticipantRefund::count()); } public function test_release_is_rejected_above_the_paid_amount(): void { $participant = $this->makeParticipant($this->makeEvent(), ['amount_paid' => 100.0]); $response = $this->release($participant, 150.0); $this->assertFalse($response->success); $this->assertStringContainsString('nicht übersteigen', $response->message); } public function test_release_accepts_a_partial_amount(): void { $participant = $this->makeParticipant($this->makeEvent()); $response = $this->release($participant, 220.0); $this->assertTrue($response->success); $this->assertEqualsWithDelta(220.0, $response->refund->amount->getAmount(), 0.001); } public function test_release_is_rejected_without_an_amount(): void { $response = $this->release($this->makeParticipant($this->makeEvent()), 0.0); $this->assertFalse($response->success); $this->assertStringContainsString('größer als 0', $response->message); } public function test_release_is_rejected_for_an_unknown_reason(): void { $response = $this->release($this->makeParticipant($this->makeEvent()), 300.0, 'erfunden'); $this->assertFalse($response->success); $this->assertStringContainsString('Erstattungsgrund', $response->message); } public function test_free_text_reason_requires_a_note(): void { $participant = $this->makeParticipant($this->makeEvent()); $response = $this->release($participant, 300.0, RefundReason::OTHER, ' '); $this->assertFalse($response->success); $this->assertStringContainsString('Erläuterung', $response->message); } public function test_note_is_only_stored_for_reasons_that_require_it(): void { $event = $this->makeEvent(); $withNote = $this->release($this->makeParticipant($event), 300.0, RefundReason::OTHER, 'Umzug'); $this->assertSame('Umzug', $withNote->refund->reason_note); $ignored = $this->release($this->makeParticipant($event), 300.0, RefundReason::SICKNESS, 'wird verworfen'); $this->assertNull($ignored->refund->reason_note); } public function test_a_second_release_is_rejected_while_one_is_open(): void { $participant = $this->makeParticipant($this->makeEvent()); $this->release($participant); $response = $this->release($participant); $this->assertFalse($response->success); $this->assertStringContainsString('läuft bereits', $response->message); $this->assertSame(1, ParticipantRefund::count()); } public function test_a_new_release_is_possible_after_a_cancellation(): void { $participant = $this->makeParticipant($this->makeEvent()); $first = $this->release($participant); new CancelRefundCommand(new CancelRefundRequest($first->refund))->execute(); $this->assertTrue($this->release($participant)->success); $this->assertSame(2, ParticipantRefund::count()); } /* |-------------------------------------------------------------------------- | Bestätigung durch den Teili |-------------------------------------------------------------------------- */ public function test_accept_stores_the_normalized_bank_details(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $response = $this->accept($refund, 'Mika Muster', 'de02 1203 0000 0000 2020 51'); $this->assertTrue($response->success); $refund->refresh(); $this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->status); $this->assertSame('Mika Muster', $refund->account_owner); $this->assertSame('DE02120300000000202051', $refund->account_iban); $this->assertNotNull($refund->accepted_at); } public function test_accept_sends_the_confirmation_with_the_document_attached(): void { $participant = $this->makeParticipant($this->makeEvent(), ['email_2' => 'eltern@example.com']); $refund = $this->release($participant)->refund; $this->accept($refund); Mail::assertSent(RefundAcceptedMail::class, 2); $expected = sprintf( 'Rueckerstattung-WM-V-20260701-%s-R.pdf', str_pad((string) $participant->invoice_sequence, 4, '0', STR_PAD_LEFT) ); Mail::assertSent(RefundAcceptedMail::class, function (RefundAcceptedMail $mail) use ($expected): bool { $attachments = $mail->attachments(); return count($attachments) === 1 && $attachments[0]->as === $expected; }); } public function test_accept_rejects_an_invalid_iban(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; // Gültige Struktur und Länge, aber falsche Prüfziffer -- ein klassischer Zahlendreher. $response = $this->accept($refund, 'Mika Muster', 'DE02120300000000202015'); $this->assertFalse($response->success); $this->assertArrayHasKey('accountIban', $response->errorTypes); $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); } /* |-------------------------------------------------------------------------- | Die Erklärung |-------------------------------------------------------------------------- */ public function test_accept_is_rejected_without_the_declaration(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $response = $this->accept($refund, declarationAccepted: false); $this->assertFalse($response->success); $this->assertArrayHasKey('declaration', $response->errorTypes); $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); $this->assertNull($refund->fresh()->account_iban); } public function test_the_declaration_cannot_be_skipped_over_http(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; // Ohne die Prüfung im Command ließe sich die Erklärung mit einem direkten Aufruf übergehen -- // und der Beleg schriebe dem Teili eine Zusicherung zu, die er nie abgegeben hat. $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [ 'accountOwner' => 'Mika Muster', 'accountIban' => 'DE02 1203 0000 0000 2020 51', ]) ->assertOk() ->assertJsonPath('status', 'error') ->assertJsonStructure(['error_types' => ['declaration']]); $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); } public function test_the_public_page_serves_the_declaration_text_name(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->get('/rueckerstattung/' . $refund->token) ->assertOk() ->assertInertia(fn ($page) => $page->where('state', 'open')); // Den Wortlaut holt die Seite über dieselbe Quelle, aus der ihn auch der Beleg nimmt. $this->get('/api/v1/core/retrieve-text-resource/' . CreateRefundDocumentCommand::DECLARATION_TEXT) ->assertOk() ->assertSee('Ich versichere', false); } public function test_accept_requires_an_account_owner(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $response = $this->accept($refund, ' '); $this->assertFalse($response->success); $this->assertArrayHasKey('accountOwner', $response->errorTypes); } public function test_accept_on_an_unknown_token_reports_no_open_refund(): void { $response = $this->accept(null); $this->assertFalse($response->success); $this->assertSame(AcceptRefundCommand::NO_OPEN_REFUND, $response->message); } public function test_accept_on_a_cancelled_refund_reports_no_open_refund(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; new CancelRefundCommand(new CancelRefundRequest($refund))->execute(); $response = $this->accept($refund->fresh()); $this->assertFalse($response->success); $this->assertSame(AcceptRefundCommand::NO_OPEN_REFUND, $response->message); } public function test_accept_twice_does_not_change_the_stored_details(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->accept($refund, 'Mika Muster', 'DE02120300000000202051'); $response = $this->accept($refund->fresh(), 'Wer Anders', 'DE02500105170137075030'); $this->assertFalse($response->success); $this->assertSame('Mika Muster', $refund->fresh()->account_owner); } /* |-------------------------------------------------------------------------- | Abbruch |-------------------------------------------------------------------------- */ public function test_cancel_marks_the_refund_and_hides_it_from_the_participant(): void { $participant = $this->makeParticipant($this->makeEvent()); $refund = $this->release($participant)->refund; $response = new CancelRefundCommand(new CancelRefundRequest($refund))->execute(); $this->assertTrue($response->success); $this->assertSame(ParticipantRefund::STATUS_CANCELLED, $refund->fresh()->status); $this->assertNotNull($refund->fresh()->cancelled_at); // Für die Anmeldung sieht es danach aus wie vor der Freigabe. $repository = new ParticipantRefundRepository(); $this->assertNull($repository->openFor($participant)); $this->assertNull($repository->currentFor($participant)); } public function test_cancel_sends_no_mail(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; Mail::fake(); new CancelRefundCommand(new CancelRefundRequest($refund))->execute(); Mail::assertNothingSent(); } public function test_cancel_is_rejected_after_acceptance(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->accept($refund); $response = new CancelRefundCommand(new CancelRefundRequest($refund->fresh()))->execute(); $this->assertFalse($response->success); $this->assertStringContainsString('bereits bestätigt', $response->message); $this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->fresh()->status); } /* |-------------------------------------------------------------------------- | Der gezahlte Beitrag bleibt unangetastet |-------------------------------------------------------------------------- */ public function test_amount_paid_is_untouched_through_the_whole_process(): void { $participant = $this->makeParticipant($this->makeEvent()); $refund = $this->release($participant)->refund; $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001); $this->accept($refund); $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001); $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001); } /* |-------------------------------------------------------------------------- | Über HTTP: Routen, Zugriffsschutz und die öffentliche Seite |-------------------------------------------------------------------------- */ private function makeAdmin(): User { return User::create([ 'username' => 'al-' . uniqid() . '@example.com', 'email' => 'al-' . uniqid() . '@example.com', 'firstname' => 'Aktions', 'lastname' => 'Leitung', 'password' => bcrypt('secret'), 'local_group' => $this->tenant->slug, 'user_role_main' => UserRole::USER_ROLE_ADMIN, 'user_role_local_group' => UserRole::USER_ROLE_USER, 'active' => true, ]); } public function test_release_over_http_creates_the_refund(): void { $this->actingAs($this->makeAdmin()); $participant = $this->makeParticipant($this->makeEvent()); $response = $this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [ 'amount' => '220,50', 'reason' => RefundReason::SICKNESS, ]); $response->assertOk()->assertJsonPath('status', 'success'); $response->assertJsonPath('refund.status', ParticipantRefund::STATUS_PENDING); $refund = ParticipantRefund::first(); $this->assertEqualsWithDelta(220.5, $refund->amount->getAmount(), 0.001); // Die Antwort trägt den Vorgang für die Liste -- aber nicht den Token-Link als Bankdaten. $response->assertJsonPath('refund.amountValue', 220.5); } public function test_release_requires_a_login(): void { $participant = $this->makeParticipant($this->makeEvent()); $this->post('/api/v1/participant-refund/' . $participant->identifier . '/release', [ 'amount' => '300', 'reason' => RefundReason::SICKNESS, ])->assertRedirect('/login'); $this->assertSame(0, ParticipantRefund::count()); } public function test_the_public_page_shows_the_open_refund(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $response = $this->get('/rueckerstattung/' . $refund->token); $response->assertOk(); $response->assertInertia(fn ($page) => $page // shouldExist=false: die Pages liegen unter app/Domains/**/Views, nicht dort, wo der // Finder von Inertia sucht. ->component('ParticipantRefund/Views/RefundPage', false) ->where('state', 'open') ->where('amount', '300,00 Euro') ->where('reason', 'Krankheitsbedingte Absage')); } public function test_the_public_page_never_exposes_bank_details(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->accept($refund); $response = $this->get('/rueckerstattung/' . $refund->fresh()->token); $response->assertOk(); $response->assertInertia(fn ($page) => $page->where('state', 'accepted')); $response->assertDontSee('DE02120300000000202051'); } public function test_the_public_page_hides_a_cancelled_refund(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; new CancelRefundCommand(new CancelRefundRequest($refund))->execute(); $this->get('/rueckerstattung/' . $refund->token) ->assertOk() ->assertInertia(fn ($page) => $page->where('state', 'unavailable')); } public function test_an_unknown_token_shows_the_same_page_as_a_cancelled_one(): void { $this->get('/rueckerstattung/' . str_repeat('x', 32)) ->assertOk() ->assertInertia(fn ($page) => $page ->where('state', 'unavailable') ->where('message', AcceptRefundCommand::NO_OPEN_REFUND)); } public function test_accept_over_http_works_without_a_login(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [ 'accountOwner' => 'Mika Muster', 'accountIban' => 'DE02 1203 0000 0000 2020 51', 'declarationAccepted' => true, ])->assertOk()->assertJsonPath('status', 'success'); $this->assertSame('DE02120300000000202051', $refund->fresh()->account_iban); } public function test_field_errors_come_back_with_status_200(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; // Der HttpClient des Frontends verwirft Antworten mit Fehlerstatus -- Feldfehler müssen daher // mit 200 zurückkommen, sonst sieht der Teili nie, was er falsch gemacht hat. $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [ 'accountOwner' => 'Mika Muster', 'accountIban' => 'DE02120300000000202015', 'declarationAccepted' => true, ]) ->assertOk() ->assertJsonPath('status', 'error') ->assertJsonStructure(['error_types' => ['accountIban']]); } public function test_the_document_is_not_public(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->accept($refund); $this->get('/api/v1/participant-refund/' . $refund->fresh()->token . '/document') ->assertRedirect('/login'); } public function test_the_document_can_be_downloaded_by_the_event_management(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->accept($refund); $this->actingAs($this->makeAdmin()); $response = $this->get('/api/v1/participant-refund/' . $refund->fresh()->token . '/document'); $response->assertOk(); $response->assertHeader('Content-Type', 'application/pdf'); $this->assertStringStartsWith('%PDF', $response->getContent()); } public function test_cancel_requires_a_login(): void { $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; $this->post('/api/v1/participant-refund/' . $refund->token . '/cancel') ->assertRedirect('/login'); $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); } /* |-------------------------------------------------------------------------- | Die Mails müssen sich auch wirklich rendern lassen |-------------------------------------------------------------------------- */ public function test_the_release_mail_renders_with_the_link(): void { $participant = $this->makeParticipant($this->makeEvent()); $refund = $this->release($participant, 220.0, RefundReason::OTHER, 'Umzug')->refund; $html = new RefundReleasedMail($participant, $refund)->render(); $this->assertStringContainsString('220,00 Euro', $html); $this->assertStringContainsString('Sonstiger Grund', $html); $this->assertStringContainsString('Umzug', $html); $this->assertStringContainsString('/rueckerstattung/' . $refund->token, $html); // Der Baustein aus emails.subparts.disclaimer braucht eventTitle und eventEmail. $this->assertStringContainsString('Sommerlager', $html); $this->assertStringContainsString('e@example.com', $html); } public function test_the_release_mail_omits_the_note_row_when_there_is_none(): void { $participant = $this->makeParticipant($this->makeEvent()); $refund = $this->release($participant)->refund; $html = new RefundReleasedMail($participant, $refund)->render(); $this->assertStringNotContainsString('Anmerkung:', $html); } public function test_the_acceptance_mail_renders_with_the_bank_details(): void { $participant = $this->makeParticipant($this->makeEvent()); $refund = $this->release($participant)->refund; $this->accept($refund); $html = new RefundAcceptedMail($participant, $refund->fresh(), 'PDF-Inhalt', 'beleg.pdf')->render(); $this->assertStringContainsString('300,00 Euro', $html); $this->assertStringContainsString('Mika Muster', $html); // Im Anschreiben steht die IBAN in Vierergruppen. $this->assertStringContainsString('DE02 1203 0000 0000 2020 51', $html); $this->assertStringContainsString('Anhang', $html); } public function test_the_acceptance_mail_renders_without_a_document(): void { $participant = $this->makeParticipant($this->makeEvent()); $refund = $this->release($participant)->refund; $this->accept($refund); $mail = new RefundAcceptedMail($participant, $refund->fresh()); $this->assertSame([], $mail->attachments()); $this->assertStringNotContainsString('Anhang', $mail->render()); } /* |-------------------------------------------------------------------------- | Gründe als Auswahl fürs Frontend |-------------------------------------------------------------------------- */ public function test_reason_options_are_ordered_and_flag_the_free_text(): void { $options = RefundReason::options(); $this->assertSame( ['sickness', 'event_cancelled', 'other'], array_column($options, 'value') ); $this->assertSame('Krankheitsbedingte Absage', $options[0]['label']); $this->assertFalse($options[0]['requiresNote']); $this->assertTrue($options[2]['requiresNote']); } public function test_reason_options_are_served_over_http(): void { $this->getJson('/api/v1/core/retrieve-refund-reasons') ->assertOk() ->assertJsonCount(3) ->assertJsonPath('0.value', RefundReason::SICKNESS) ->assertJsonPath('2.requiresNote', true); } }