From 1a87871e94a272943e55d666250e3c64f74b70af Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Thu, 23 Apr 2026 18:54:58 +0200 Subject: [PATCH] feat(form-builder): extend public form backend for S3a PR 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Seed AVAILABILITY_PICKER and SECTION_PRIORITY demo fields in the event_registration showcase, and augment seedEchtFeesten with a parent-level VOLUNTEER time slot pair + a standard registration- visible section whose name duplicates a child section so the PublicFormController dedup path is exercised end-to-end. - Validate SECTION_PRIORITY value shape in FormValueService: arrays of { section_id, priority } with unique section_ids + priorities in 1..5, max 5 entries, and section_ids scoped to the schema's event tree (parent + children). Error envelope is the standard VALIDATION_FAILED FieldValidationException shape so the portal renders errors next to the field. - Enrich admin-facing FormSubmissionResource with a nested identity_match block mirroring the PublicFormSubmissionResource contract (status only; leaves room for future matched_user_id / confidence). - Lock in the FORM-05 stub contract with 6 tests against the existing TriggerPersonIdentityMatchOnFormSubmit listener (no new listener was needed — the current one already writes 'pending' for public event_registration submissions per ARCH §31.1). - 24 new backend assertions across seeder, shape validation, listener state matrix, and resource serialisation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FormBuilder/FormSubmissionResource.php | 5 + .../Services/FormBuilder/FormValueService.php | 130 +++++++++ api/database/seeders/DevSeeder.php | 20 ++ api/database/seeders/FormBuilderDevSeeder.php | 24 ++ ...ormSubmissionResourceIdentityMatchTest.php | 100 +++++++ ...gerPersonIdentityMatchOnFormSubmitTest.php | 193 +++++++++++++ .../Public/PublicFormSeederTest.php | 258 ++++++++++++++++++ .../SectionPriorityValueValidationTest.php | 227 +++++++++++++++ 8 files changed, 957 insertions(+) create mode 100644 api/tests/Feature/FormBuilder/FormSubmissionResourceIdentityMatchTest.php create mode 100644 api/tests/Feature/FormBuilder/Listeners/TriggerPersonIdentityMatchOnFormSubmitTest.php create mode 100644 api/tests/Feature/FormBuilder/Public/PublicFormSeederTest.php create mode 100644 api/tests/Feature/FormBuilder/SectionPriorityValueValidationTest.php diff --git a/api/app/Http/Resources/FormBuilder/FormSubmissionResource.php b/api/app/Http/Resources/FormBuilder/FormSubmissionResource.php index 011dc8f2..f68ea9ea 100644 --- a/api/app/Http/Resources/FormBuilder/FormSubmissionResource.php +++ b/api/app/Http/Resources/FormBuilder/FormSubmissionResource.php @@ -64,6 +64,11 @@ final class FormSubmissionResource extends JsonResource 'first_interacted_at' => optional($this->first_interacted_at)->toIso8601String(), 'submission_duration_seconds' => $this->submission_duration_seconds, 'is_test' => (bool) $this->is_test, + 'identity_match' => $this->identity_match_status !== null && $this->identity_match_status !== '' + ? ['status' => $this->identity_match_status instanceof \BackedEnum + ? $this->identity_match_status->value + : (string) $this->identity_match_status] + : null, 'values' => $values, 'section_statuses' => $this->sectionStatuses->map(fn ($s) => [ 'form_schema_section_id' => $s->form_schema_section_id, diff --git a/api/app/Services/FormBuilder/FormValueService.php b/api/app/Services/FormBuilder/FormValueService.php index 01c85a25..b34de01b 100644 --- a/api/app/Services/FormBuilder/FormValueService.php +++ b/api/app/Services/FormBuilder/FormValueService.php @@ -5,10 +5,13 @@ declare(strict_types=1); namespace App\Services\FormBuilder; use App\Enums\FormBuilder\FormFieldType; +use App\Models\Event; +use App\Models\FestivalSection; use App\Models\FormBuilder\FormField; use App\Models\FormBuilder\FormSchema; use App\Models\FormBuilder\FormSubmission; use App\Models\FormBuilder\FormValue; +use App\Models\Scopes\OrganisationScope; use App\Models\User; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Support\Facades\DB; @@ -85,6 +88,13 @@ final class FormValueService $errors = []; $rules = is_array($field->validation_rules) ? $field->validation_rules : []; + if ($field->field_type === FormFieldType::SECTION_PRIORITY->value) { + $shapeErrors = $this->validateSectionPriorityShape($raw, $submission); + if ($shapeErrors !== []) { + return $shapeErrors; + } + } + if ($raw === null || $raw === '' || $raw === []) { return $errors; } @@ -119,6 +129,126 @@ final class FormValueService return $errors; } + /** + * Shape validation for SECTION_PRIORITY values per ARCH §5.1: + * `{ section_id, priority }[]` with unique section_ids, unique + * priorities in 1..5, max 5 entries, and section_ids scoped to + * the schema's owner event (parent festival + children). + * + * Empty values pass — the `is_required` check lives one layer up in + * the request rule builder. + * + * @return array Dutch-language messages for the portal + */ + private function validateSectionPriorityShape(mixed $raw, FormSubmission $submission): array + { + if ($raw === null || $raw === [] || $raw === '') { + return []; + } + + if (! is_array($raw) || array_is_list($raw) === false) { + return ['Ongeldig formaat voor sectievoorkeuren.']; + } + + $errors = []; + $count = count($raw); + if ($count > 5) { + $errors[] = 'Je kunt maximaal 5 voorkeuren opgeven.'; + } + + $sectionIds = []; + $priorities = []; + foreach ($raw as $index => $entry) { + if (! is_array($entry)) { + $errors[] = sprintf('Ongeldig voorkeur-element op positie %d.', $index + 1); + + continue; + } + + $sectionId = $entry['section_id'] ?? null; + $priority = $entry['priority'] ?? null; + + if (! is_string($sectionId) || $sectionId === '') { + $errors[] = sprintf('section_id ontbreekt op positie %d.', $index + 1); + } + if (! is_int($priority) && ! (is_string($priority) && ctype_digit($priority))) { + $errors[] = sprintf('priority ontbreekt of is ongeldig op positie %d.', $index + 1); + + continue; + } + + $priorityInt = (int) $priority; + if ($priorityInt < 1 || $priorityInt > 5) { + $errors[] = sprintf('priority moet tussen 1 en 5 liggen (positie %d).', $index + 1); + } + + if (is_string($sectionId) && $sectionId !== '') { + $sectionIds[] = $sectionId; + } + $priorities[] = $priorityInt; + } + + if (count($sectionIds) !== count(array_unique($sectionIds))) { + $errors[] = 'Dezelfde sectie mag slechts één keer worden opgegeven.'; + } + if (count($priorities) !== count(array_unique($priorities))) { + $errors[] = 'Elke prioriteit mag slechts één keer worden toegekend.'; + } + + if ($errors !== []) { + return $errors; + } + + $allowed = $this->allowedSectionIdsForSubmission($submission); + foreach ($sectionIds as $id) { + if (! in_array($id, $allowed, true)) { + $errors[] = 'Eén of meer secties horen niet bij dit evenement.'; + break; + } + } + + return $errors; + } + + /** + * Resolve the set of festival_sections visible within this schema's + * event scope. Mirrors PublicFormController::festivalEventIds so the + * shape check and the public `/sections` endpoint agree on what is + * addressable. + * + * @return array + */ + private function allowedSectionIdsForSubmission(FormSubmission $submission): array + { + $schema = $submission->schema; + if ($schema === null || $schema->owner_type !== 'event' || $schema->owner_id === null) { + return []; + } + + $event = Event::query() + ->withoutGlobalScope(OrganisationScope::class) + ->find($schema->owner_id); + if ($event === null) { + return []; + } + + $childIds = Event::query() + ->withoutGlobalScope(OrganisationScope::class) + ->where('parent_event_id', $event->id) + ->pluck('id') + ->map(fn ($id) => (string) $id) + ->all(); + + $eventIds = array_values(array_unique(array_merge([(string) $event->id], $childIds))); + + return FestivalSection::query() + ->withoutGlobalScope(OrganisationScope::class) + ->whereIn('event_id', $eventIds) + ->pluck('id') + ->map(fn ($id) => (string) $id) + ->all(); + } + private function writeValue(FormSubmission $submission, FormField $field, mixed $raw): void { $payload = $this->normalisePayload($field, $raw); diff --git a/api/database/seeders/DevSeeder.php b/api/database/seeders/DevSeeder.php index 8bda293a..d55bc818 100644 --- a/api/database/seeders/DevSeeder.php +++ b/api/database/seeders/DevSeeder.php @@ -285,6 +285,21 @@ class DevSeeder extends Seeder 'show_in_registration' => false, ]); + // Parent-level standard section that mirrors a child name — exercises + // PublicFormController::sections() dedup across parent+child. + FestivalSection::create([ + 'event_id' => $festival->id, + 'name' => 'Hoofdpodium Bar', + 'type' => 'standard', + 'category' => 'Bar', + 'icon' => 'tabler-beer', + 'sort_order' => 5, + 'responder_self_checkin' => true, + 'crew_auto_accepts' => true, + 'show_in_registration' => true, + 'registration_description' => 'Overkoepelende barinzet — je draait mee op het hoofdpodium over alle dagen', + ]); + // ── Sub-event sections (5 per sub-event) ── $sectionDefs = [ @@ -324,6 +339,11 @@ class DevSeeder extends Seeder $fSlots['nacht_za'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Nachtsecurity za→zo', 'person_type' => 'CREW', 'date' => '2026-07-11', 'start_time' => '23:00', 'end_time' => '07:00', 'duration_hours' => 8.00]); $fSlots['afbraak'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Afbraak', 'person_type' => 'CREW', 'date' => '2026-07-13', 'start_time' => '08:00', 'end_time' => '18:00', 'duration_hours' => 10.00]); + // Parent-level VOLUNTEER slots — ensures PublicFormController::timeSlots + // surfaces both parent and child events in the merged response. + $fSlots['vol_opbouw_vr'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Vrijwilligers opbouw vrijdag', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-10', 'start_time' => '09:00', 'end_time' => '12:00', 'duration_hours' => 3.00]); + $fSlots['vol_afbraak_zo'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Vrijwilligers afbraak zondag', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-13', 'start_time' => '10:00', 'end_time' => '16:00', 'duration_hours' => 6.00]); + // ── Sub-event time slots (program-specific) ── $ts = []; diff --git a/api/database/seeders/FormBuilderDevSeeder.php b/api/database/seeders/FormBuilderDevSeeder.php index 14f9c683..3fadc6d2 100644 --- a/api/database/seeders/FormBuilderDevSeeder.php +++ b/api/database/seeders/FormBuilderDevSeeder.php @@ -293,6 +293,29 @@ final class FormBuilderDevSeeder 'display_width' => 'full', 'value_storage_hint' => FormValueStorageHint::JSON, ], + [ + 'type' => FormFieldType::AVAILABILITY_PICKER, + 'slug' => 'beschikbaarheid', + 'label' => 'Wanneer ben je beschikbaar?', + 'help_text' => 'Vink alle dagdelen aan waarop je kunt werken.', + 'is_required' => true, + 'is_filterable' => false, + 'display_width' => 'full', + 'value_storage_hint' => FormValueStorageHint::JSON, + ], + [ + 'type' => FormFieldType::SECTION_PRIORITY, + 'slug' => 'sectie_voorkeur', + 'label' => 'Bij welke sectie wil je het liefst werken?', + 'help_text' => 'Sleep je voorkeuren in volgorde. Nummer 1 is je eerste keuze.', + // UI soft cap; the hard cap of 5 lives in + // FormValueService shape validation. + 'validation_rules' => ['max_priorities' => 3], + 'is_required' => false, + 'is_filterable' => false, + 'display_width' => 'full', + 'value_storage_hint' => FormValueStorageHint::JSON, + ], [ 'type' => FormFieldType::TEXTAREA, 'slug' => 'opmerkingen', @@ -340,6 +363,7 @@ final class FormBuilderDevSeeder 'field_type' => $def['type']->value, 'slug' => $def['slug'], 'label' => $def['label'], + 'help_text' => $def['help_text'] ?? null, 'options' => $def['options'] ?? null, 'validation_rules' => $def['validation_rules'] ?? null, 'is_required' => $def['is_required'] ?? false, diff --git a/api/tests/Feature/FormBuilder/FormSubmissionResourceIdentityMatchTest.php b/api/tests/Feature/FormBuilder/FormSubmissionResourceIdentityMatchTest.php new file mode 100644 index 00000000..15cb9b97 --- /dev/null +++ b/api/tests/Feature/FormBuilder/FormSubmissionResourceIdentityMatchTest.php @@ -0,0 +1,100 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->schema = FormSchema::factory()->create(['organisation_id' => $this->org->id]); + $this->admin = User::factory()->create(); + $this->org->users()->attach($this->admin, ['role' => 'org_admin']); + } + + private function toArray(FormSubmission $submission): array + { + $request = request(); + Sanctum::actingAs($this->admin); + + return (new FormSubmissionResource($submission->fresh()))->toArray($request); + } + + public function test_pending_status_serialises_as_nested_object(): void + { + $submission = FormSubmission::create([ + 'form_schema_id' => $this->schema->id, + 'status' => FormSubmissionStatus::SUBMITTED->value, + 'submitted_at' => now(), + 'is_test' => false, + 'identity_match_status' => 'pending', + ]); + + $array = $this->toArray($submission); + + $this->assertIsArray($array['identity_match']); + $this->assertSame(['status' => 'pending'], $array['identity_match']); + } + + public function test_null_status_serialises_as_null(): void + { + $submission = FormSubmission::create([ + 'form_schema_id' => $this->schema->id, + 'status' => FormSubmissionStatus::SUBMITTED->value, + 'submitted_at' => now(), + 'is_test' => false, + ]); + + $array = $this->toArray($submission); + + $this->assertNull($array['identity_match']); + } + + public function test_api_endpoint_returns_matching_shape(): void + { + Sanctum::actingAs($this->admin); + + $submission = FormSubmission::create([ + 'form_schema_id' => $this->schema->id, + 'status' => FormSubmissionStatus::SUBMITTED->value, + 'submitted_at' => now(), + 'is_test' => false, + 'identity_match_status' => 'matched', + ]); + + $response = $this->getJson("/api/v1/organisations/{$this->org->id}/forms/submissions/{$submission->id}"); + + $response->assertOk() + ->assertJsonPath('data.identity_match.status', 'matched'); + } +} diff --git a/api/tests/Feature/FormBuilder/Listeners/TriggerPersonIdentityMatchOnFormSubmitTest.php b/api/tests/Feature/FormBuilder/Listeners/TriggerPersonIdentityMatchOnFormSubmitTest.php new file mode 100644 index 00000000..1afc9515 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Listeners/TriggerPersonIdentityMatchOnFormSubmitTest.php @@ -0,0 +1,193 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create(['organisation_id' => $this->org->id]); + $this->crowdType = CrowdType::factory()->systemType('CREW')->create([ + 'organisation_id' => $this->org->id, + ]); + $this->schema = FormSchema::factory()->create([ + 'organisation_id' => $this->org->id, + 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'owner_type' => 'event', + 'owner_id' => $this->event->id, + ]); + } + + /** + * @param array $overrides + */ + private function submit(array $overrides = []): FormSubmission + { + $submission = FormSubmission::create(array_merge([ + 'form_schema_id' => $this->schema->id, + 'subject_type' => null, + 'subject_id' => null, + 'status' => FormSubmissionStatus::SUBMITTED->value, + 'submitted_at' => now(), + 'is_test' => false, + ], $overrides)); + + FormSubmissionSubmitted::dispatch($submission->fresh()); + + return $submission->fresh(); + } + + public function test_public_event_registration_submission_is_marked_pending(): void + { + $submission = $this->submit(); + + $this->assertSame('pending', $submission->identity_match_status); + } + + public function test_linked_person_is_marked_matched(): void + { + $user = User::factory()->create(); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => $user->id, + ]); + + $submission = $this->submit([ + 'subject_type' => 'person', + 'subject_id' => $person->id, + ]); + + $this->assertSame('matched', $submission->identity_match_status); + } + + public function test_unlinked_person_with_no_matches_is_marked_none(): void + { + // Unique email + no matching user in the org — detectMatches + // returns an empty collection, listener writes 'none'. + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + 'email' => 'nobody-matches@example.test', + 'first_name' => 'Zzz', + 'last_name' => 'NoMatchPossible', + 'registration_source' => 'self', + ]); + + $submission = $this->submit([ + 'subject_type' => 'person', + 'subject_id' => $person->id, + ]); + + $this->assertSame('none', $submission->identity_match_status); + } + + public function test_unlinked_person_with_pending_match_is_marked_pending(): void + { + // Person email matches an existing org user's email — detectMatches + // returns a non-empty collection, listener writes 'pending'. + $user = User::factory()->create(['email' => 'match@example.test']); + $this->org->users()->attach($user, ['role' => 'org_member']); + + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + 'email' => 'match@example.test', + ]); + + $submission = $this->submit([ + 'subject_type' => 'person', + 'subject_id' => $person->id, + ]); + + $this->assertSame('pending', $submission->identity_match_status); + } + + public function test_non_event_registration_submission_is_left_untouched(): void + { + $otherSchema = FormSchema::factory()->create([ + 'organisation_id' => $this->org->id, + 'purpose' => FormPurpose::PUBLIC_COMPLAINT, + 'owner_type' => 'event', + 'owner_id' => $this->event->id, + ]); + + $submission = FormSubmission::create([ + 'form_schema_id' => $otherSchema->id, + 'subject_type' => null, + 'subject_id' => null, + 'status' => FormSubmissionStatus::SUBMITTED->value, + 'submitted_at' => now(), + 'is_test' => false, + ]); + + FormSubmissionSubmitted::dispatch($submission->fresh()); + + $this->assertNull($submission->fresh()->identity_match_status); + } + + public function test_submission_with_no_schema_is_left_untouched(): void + { + // Guard branch: if the schema relation can't resolve, the listener + // must early-return without touching the status and without throwing + // (so sibling listeners keep executing). + $submission = FormSubmission::create([ + 'form_schema_id' => $this->schema->id, + 'subject_type' => null, + 'subject_id' => null, + 'status' => FormSubmissionStatus::SUBMITTED->value, + 'submitted_at' => now(), + 'is_test' => false, + ]); + + // Soft-delete the schema so fresh(['schema']) returns null for the relation. + FormSchema::query()->whereKey($this->schema->id)->delete(); + + FormSubmissionSubmitted::dispatch($submission->fresh()); + + $this->assertNull($submission->fresh()->identity_match_status); + } +} diff --git a/api/tests/Feature/FormBuilder/Public/PublicFormSeederTest.php b/api/tests/Feature/FormBuilder/Public/PublicFormSeederTest.php new file mode 100644 index 00000000..01fb8956 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Public/PublicFormSeederTest.php @@ -0,0 +1,258 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + + // Festival parent + 2 children — mirrors DevSeeder::seedEchtFeesten + // so the controller's festival-aware merge is exercised. + $this->festival = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'name' => 'Testfestival 2026', + 'event_type' => 'festival', + 'parent_event_id' => null, + 'status' => 'registration_open', + ]); + $this->dag1 = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'parent_event_id' => $this->festival->id, + 'name' => 'Dag 1', + 'event_type' => 'event', + 'status' => 'registration_open', + ]); + $this->dag2 = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'parent_event_id' => $this->festival->id, + 'name' => 'Dag 2', + 'event_type' => 'event', + 'status' => 'registration_open', + ]); + + // Time slots — parent has 1 VOLUNTEER + 1 CREW (CREW must be + // filtered out); children have 3 VOLUNTEER slots across 2 dates. + TimeSlot::factory()->create([ + 'event_id' => $this->festival->id, + 'name' => 'Vrijwilligers opbouw', + 'person_type' => 'VOLUNTEER', + 'date' => '2026-07-10', + 'start_time' => '09:00:00', + 'end_time' => '12:00:00', + ]); + TimeSlot::factory()->create([ + 'event_id' => $this->festival->id, + 'name' => 'Crew-only nacht', + 'person_type' => 'CREW', + 'date' => '2026-07-10', + 'start_time' => '23:00:00', + 'end_time' => '07:00:00', + ]); + TimeSlot::factory()->create([ + 'event_id' => $this->dag1->id, + 'name' => 'Dag 1 middag', + 'person_type' => 'VOLUNTEER', + 'date' => '2026-07-11', + 'start_time' => '12:00:00', + 'end_time' => '18:00:00', + ]); + TimeSlot::factory()->create([ + 'event_id' => $this->dag1->id, + 'name' => 'Dag 1 avond', + 'person_type' => 'VOLUNTEER', + 'date' => '2026-07-11', + 'start_time' => '18:00:00', + 'end_time' => '23:00:00', + ]); + TimeSlot::factory()->create([ + 'event_id' => $this->dag2->id, + 'name' => 'Dag 2 middag', + 'person_type' => 'VOLUNTEER', + 'date' => '2026-07-12', + 'start_time' => '12:00:00', + 'end_time' => '18:00:00', + ]); + + // Sections — positive (standard+registration) + duplicate name + // across parent/child + negative cases (cross_event, hidden). + FestivalSection::factory()->create([ + 'event_id' => $this->festival->id, + 'name' => 'Bar', + 'type' => 'standard', + 'sort_order' => 1, + 'show_in_registration' => true, + 'registration_description' => 'Overkoepelende bar', + ]); + FestivalSection::factory()->create([ + 'event_id' => $this->dag1->id, + 'name' => 'Bar', // Duplicate name with parent + 'type' => 'standard', + 'sort_order' => 2, + 'show_in_registration' => true, + ]); + FestivalSection::factory()->create([ + 'event_id' => $this->dag1->id, + 'name' => 'Hospitality', + 'type' => 'standard', + 'sort_order' => 3, + 'show_in_registration' => true, + ]); + FestivalSection::factory()->create([ + 'event_id' => $this->dag2->id, + 'name' => 'Techniek', + 'type' => 'standard', + 'sort_order' => 4, + 'show_in_registration' => true, + ]); + // Negative: hidden from registration + FestivalSection::factory()->create([ + 'event_id' => $this->dag1->id, + 'name' => 'Intern overleg', + 'type' => 'standard', + 'sort_order' => 10, + 'show_in_registration' => false, + ]); + // Negative: cross_event + FestivalSection::factory()->create([ + 'event_id' => $this->festival->id, + 'name' => 'EHBO', + 'type' => 'cross_event', + 'sort_order' => 11, + 'show_in_registration' => true, + ]); + + $this->schema = FormBuilderDevSeeder::seedEventRegistrationShowcaseSchema( + $this->org, + $this->festival, + ); + } + + public function test_showcase_schema_resolves_via_its_public_token(): void + { + $response = $this->getJson("/api/v1/public/forms/{$this->schema->public_token}"); + + $response->assertOk() + ->assertJsonPath('data.id', $this->schema->id); + } + + public function test_showcase_schema_contains_availability_picker_and_section_priority(): void + { + $response = $this->getJson("/api/v1/public/forms/{$this->schema->public_token}"); + + $response->assertOk(); + $fields = collect($response->json('data.fields'))->keyBy('slug'); + + $this->assertArrayHasKey('beschikbaarheid', $fields); + $this->assertSame(FormFieldType::AVAILABILITY_PICKER->value, $fields['beschikbaarheid']['field_type']); + $this->assertSame('full', $fields['beschikbaarheid']['display_width']); + + $this->assertArrayHasKey('sectie_voorkeur', $fields); + $this->assertSame(FormFieldType::SECTION_PRIORITY->value, $fields['sectie_voorkeur']['field_type']); + $this->assertSame('full', $fields['sectie_voorkeur']['display_width']); + $this->assertSame(['max_priorities' => 3], $fields['sectie_voorkeur']['validation_rules']); + } + + public function test_time_slots_endpoint_returns_at_least_four_volunteer_rows(): void + { + $response = $this->getJson("/api/v1/public/forms/{$this->schema->public_token}/time-slots"); + + $response->assertOk(); + $rows = $response->json('data'); + + $this->assertIsArray($rows); + $this->assertGreaterThanOrEqual(4, count($rows)); + } + + public function test_time_slots_endpoint_surfaces_both_parent_and_child_events(): void + { + $response = $this->getJson("/api/v1/public/forms/{$this->schema->public_token}/time-slots"); + + $response->assertOk(); + $eventNames = collect($response->json('data'))->pluck('event_name')->unique()->values()->all(); + + $this->assertContains('Testfestival 2026', $eventNames); + $this->assertTrue(in_array('Dag 1', $eventNames, true) || in_array('Dag 2', $eventNames, true)); + } + + public function test_time_slots_endpoint_filters_out_non_volunteer_rows(): void + { + $response = $this->getJson("/api/v1/public/forms/{$this->schema->public_token}/time-slots"); + + $response->assertOk(); + $names = collect($response->json('data'))->pluck('name')->all(); + + $this->assertNotContains('Crew-only nacht', $names); + } + + public function test_sections_endpoint_returns_only_standard_registration_visible_rows(): void + { + $response = $this->getJson("/api/v1/public/forms/{$this->schema->public_token}/sections"); + + $response->assertOk(); + $names = collect($response->json('data'))->pluck('name')->all(); + + $this->assertContains('Bar', $names); + $this->assertContains('Hospitality', $names); + $this->assertContains('Techniek', $names); + } + + public function test_sections_endpoint_excludes_hidden_and_cross_event_rows(): void + { + $response = $this->getJson("/api/v1/public/forms/{$this->schema->public_token}/sections"); + + $response->assertOk(); + $names = collect($response->json('data'))->pluck('name')->all(); + + $this->assertNotContains('Intern overleg', $names); + $this->assertNotContains('EHBO', $names); + } + + public function test_sections_endpoint_dedupes_duplicate_names_across_parent_and_child(): void + { + $response = $this->getJson("/api/v1/public/forms/{$this->schema->public_token}/sections"); + + $response->assertOk(); + $names = collect($response->json('data'))->pluck('name')->all(); + $barCount = collect($names)->filter(fn ($n) => $n === 'Bar')->count(); + + $this->assertSame(1, $barCount, 'Duplicate "Bar" across parent and child should be deduped.'); + } +} diff --git a/api/tests/Feature/FormBuilder/SectionPriorityValueValidationTest.php b/api/tests/Feature/FormBuilder/SectionPriorityValueValidationTest.php new file mode 100644 index 00000000..f7d4de04 --- /dev/null +++ b/api/tests/Feature/FormBuilder/SectionPriorityValueValidationTest.php @@ -0,0 +1,227 @@ + */ + private array $sections; + + private FormSchema $schema; + + private FormField $field; + + protected function setUp(): void + { + parent::setUp(); + $this->seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'event_type' => 'event', + ]); + + $this->sections = []; + foreach (['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot'] as $i => $name) { + $this->sections[] = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + 'name' => $name, + 'sort_order' => $i + 1, + 'show_in_registration' => true, + 'type' => 'standard', + ]); + } + + $this->schema = FormSchema::factory()->create([ + 'organisation_id' => $this->org->id, + 'owner_type' => 'event', + 'owner_id' => $this->event->id, + 'purpose' => FormPurpose::EVENT_REGISTRATION, + ]); + + $this->field = FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'field_type' => FormFieldType::SECTION_PRIORITY->value, + 'slug' => 'sectie_voorkeur', + 'is_portal_visible' => true, + 'is_admin_only' => false, + ]); + } + + private function newDraftSubmission(): FormSubmission + { + return FormSubmission::create([ + 'form_schema_id' => $this->schema->id, + 'subject_type' => null, + 'subject_id' => null, + 'status' => FormSubmissionStatus::DRAFT->value, + 'is_test' => false, + 'submitted_in_locale' => 'nl', + ]); + } + + public function test_happy_path_three_unique_entries_passes(): void + { + $submission = $this->newDraftSubmission(); + + app(FormValueService::class)->upsertMany( + $submission, + ['sectie_voorkeur' => [ + ['section_id' => $this->sections[0]->id, 'priority' => 1], + ['section_id' => $this->sections[1]->id, 'priority' => 2], + ['section_id' => $this->sections[2]->id, 'priority' => 3], + ]], + null, + ); + + $this->assertDatabaseHas('form_values', [ + 'form_submission_id' => $submission->id, + 'form_field_id' => $this->field->id, + ]); + } + + public function test_duplicate_section_id_fails_validation(): void + { + $submission = $this->newDraftSubmission(); + + try { + app(FormValueService::class)->upsertMany( + $submission, + ['sectie_voorkeur' => [ + ['section_id' => $this->sections[0]->id, 'priority' => 1], + ['section_id' => $this->sections[0]->id, 'priority' => 2], + ]], + null, + ); + $this->fail('Expected FieldValidationException for duplicate section_id.'); + } catch (FieldValidationException $e) { + $this->assertSame('VALIDATION_FAILED', $e->publicCode); + $this->assertArrayHasKey('sectie_voorkeur', $e->fieldErrors); + } + } + + public function test_duplicate_priority_fails_validation(): void + { + $submission = $this->newDraftSubmission(); + + try { + app(FormValueService::class)->upsertMany( + $submission, + ['sectie_voorkeur' => [ + ['section_id' => $this->sections[0]->id, 'priority' => 1], + ['section_id' => $this->sections[1]->id, 'priority' => 1], + ]], + null, + ); + $this->fail('Expected FieldValidationException for duplicate priority.'); + } catch (FieldValidationException $e) { + $this->assertSame('VALIDATION_FAILED', $e->publicCode); + $this->assertArrayHasKey('sectie_voorkeur', $e->fieldErrors); + } + } + + public function test_out_of_range_priority_fails_validation(): void + { + $submission = $this->newDraftSubmission(); + + $this->expectException(FieldValidationException::class); + app(FormValueService::class)->upsertMany( + $submission, + ['sectie_voorkeur' => [ + ['section_id' => $this->sections[0]->id, 'priority' => 6], + ]], + null, + ); + } + + public function test_more_than_five_entries_fails_validation(): void + { + $submission = $this->newDraftSubmission(); + + $this->expectException(FieldValidationException::class); + app(FormValueService::class)->upsertMany( + $submission, + ['sectie_voorkeur' => [ + ['section_id' => $this->sections[0]->id, 'priority' => 1], + ['section_id' => $this->sections[1]->id, 'priority' => 2], + ['section_id' => $this->sections[2]->id, 'priority' => 3], + ['section_id' => $this->sections[3]->id, 'priority' => 4], + ['section_id' => $this->sections[4]->id, 'priority' => 5], + ['section_id' => $this->sections[5]->id, 'priority' => 1], + ]], + null, + ); + } + + public function test_section_id_outside_event_scope_fails_validation(): void + { + $submission = $this->newDraftSubmission(); + + $otherEvent = Event::factory()->create(['organisation_id' => $this->org->id]); + $alienSection = FestivalSection::factory()->create([ + 'event_id' => $otherEvent->id, + 'name' => 'Alien', + 'show_in_registration' => true, + 'type' => 'standard', + ]); + + try { + app(FormValueService::class)->upsertMany( + $submission, + ['sectie_voorkeur' => [ + ['section_id' => $alienSection->id, 'priority' => 1], + ]], + null, + ); + $this->fail('Expected FieldValidationException for out-of-scope section_id.'); + } catch (FieldValidationException $e) { + $this->assertSame('VALIDATION_FAILED', $e->publicCode); + $this->assertArrayHasKey('sectie_voorkeur', $e->fieldErrors); + } + } + + public function test_malformed_element_fails_validation(): void + { + $submission = $this->newDraftSubmission(); + + $this->expectException(FieldValidationException::class); + app(FormValueService::class)->upsertMany( + $submission, + ['sectie_voorkeur' => [ + 'not-an-object', + ['section_id' => $this->sections[0]->id], // missing priority + ]], + null, + ); + } +}