From 9b1bf0e13d51057c98a79e48ba7381d5b223b43e Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 17 Apr 2026 23:03:28 +0200 Subject: [PATCH] =?UTF-8?q?test(form-builder):=20public=20form=20API=20?= =?UTF-8?q?=E2=80=94=2036=20new=20tests=20covering=20S2c=20deliverables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight new feature test files under tests/Feature/Api/V1/Public/FormBuilder/. Full suite 857 → 893 green. - PublicFormSchemaResourceTest (3) — TAG_PICKER available_tags grouped by category, tag_categories filter, version + opened_at top-level. - PublicFormTimeSlotsTest (4) — volunteer-only filter, festival children included, 410 TOKEN_EXPIRED on rotated-past-grace, 404 SCHEMA_NOT_FOUND on unknown token. - PublicFormSectionsTest (2) — show_in_registration + type=standard filter, dedup-by-name across festival children. - PublicFormDraftLifecycleTest (8) — POST creates draft (201), idempotent replay returns 200 w/ same id, idempotency_key required, PUT partial update increments auto_save_count, submit happy path, 409 SUBMISSION_ALREADY_SUBMITTED on re-submit, schema_drift flagged when organiser edits mid-draft, 404 when submission_id belongs to another schema. - PublicFormValidationTest (6) — EMAIL format, NUMBER type, SELECT option list, NUMBER min/max from validation_rules, required-at-submit enforcement, draft-save tolerates missing required. - PublicFormSubmissionResourceTest (3) — no PII echo (public_submitter_name/email/ip suppressed), admin metadata (review_status/schema_snapshot/reviewed_by) absent, identity_match shape with Dutch message on pending. - PublicFormErrorEnvelopeTest (5) — SCHEMA_NOT_FOUND, TOKEN_EXPIRED, SCHEMA_UNPUBLISHED codes; 422 VALIDATION_FAILED carries errors; 429 RATE_LIMITED carries Retry-After header. - IdentityMatchOnSubmitTest (5) — event_registration triggers matched/none/pending per person state; non-event_registration purpose does not trigger; public-subject submissions write pending. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FormBuilder/IdentityMatchOnSubmitTest.php | 171 +++++++++++++++ .../PublicFormDraftLifecycleTest.php | 204 ++++++++++++++++++ .../PublicFormErrorEnvelopeTest.php | 118 ++++++++++ .../PublicFormSchemaResourceTest.php | 97 +++++++++ .../FormBuilder/PublicFormSectionsTest.php | 91 ++++++++ .../PublicFormSubmissionResourceTest.php | 120 +++++++++++ .../FormBuilder/PublicFormTimeSlotsTest.php | 101 +++++++++ .../FormBuilder/PublicFormValidationTest.php | 148 +++++++++++++ 8 files changed, 1050 insertions(+) create mode 100644 api/tests/Feature/Api/V1/Public/FormBuilder/IdentityMatchOnSubmitTest.php create mode 100644 api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormDraftLifecycleTest.php create mode 100644 api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormErrorEnvelopeTest.php create mode 100644 api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSchemaResourceTest.php create mode 100644 api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSectionsTest.php create mode 100644 api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSubmissionResourceTest.php create mode 100644 api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormTimeSlotsTest.php create mode 100644 api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormValidationTest.php diff --git a/api/tests/Feature/Api/V1/Public/FormBuilder/IdentityMatchOnSubmitTest.php b/api/tests/Feature/Api/V1/Public/FormBuilder/IdentityMatchOnSubmitTest.php new file mode 100644 index 00000000..6dea16c5 --- /dev/null +++ b/api/tests/Feature/Api/V1/Public/FormBuilder/IdentityMatchOnSubmitTest.php @@ -0,0 +1,171 @@ +seed(RoleSeeder::class); + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create(['organisation_id' => $this->org->id]); + $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->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, + ]); + FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'naam', + 'is_portal_visible' => true, + ]); + } + + public function test_event_registration_triggers_matched_when_person_has_user(): 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 = FormSubmission::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'subject_type' => 'person', + 'subject_id' => $person->id, + 'status' => 'submitted', + 'submitted_at' => now(), + ]); + + FormSubmissionSubmitted::dispatch($submission->fresh()); + + $this->assertSame('matched', $submission->fresh()->identity_match_status); + } + + public function test_event_registration_triggers_none_when_person_unlinked_no_match(): void + { + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + 'email' => 'nobody@nowhere.test', + 'first_name' => 'Xyz', + 'last_name' => 'NoMatch', + ]); + + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'subject_type' => 'person', + 'subject_id' => $person->id, + 'status' => 'submitted', + 'submitted_at' => now(), + ]); + + FormSubmissionSubmitted::dispatch($submission->fresh()); + + $this->assertSame('none', $submission->fresh()->identity_match_status); + } + + public function test_event_registration_triggers_pending_when_matcher_finds_candidate(): void + { + // Pre-seed a User with a specific email, then a Person in the same + // org with the matching email → detectMatches should create a + // PersonIdentityMatch with status=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', + 'first_name' => 'Anne', + 'last_name' => 'Match', + ]); + + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'subject_type' => 'person', + 'subject_id' => $person->id, + 'status' => 'submitted', + 'submitted_at' => now(), + ]); + + FormSubmissionSubmitted::dispatch($submission->fresh()); + + $this->assertSame('pending', $submission->fresh()->identity_match_status); + } + + public function test_non_event_registration_purpose_does_not_trigger(): void + { + $otherSchema = FormSchema::factory()->create([ + 'organisation_id' => $this->org->id, + 'purpose' => FormPurpose::FEEDBACK, + ]); + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $otherSchema->id, + 'subject_type' => null, + 'subject_id' => null, + 'status' => 'submitted', + 'submitted_at' => now(), + ]); + + FormSubmissionSubmitted::dispatch($submission->fresh()); + + $this->assertNull($submission->fresh()->identity_match_status); + } + + public function test_public_submission_marked_pending(): void + { + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'subject_type' => null, + 'subject_id' => null, + 'status' => 'submitted', + 'submitted_at' => now(), + ]); + + FormSubmissionSubmitted::dispatch($submission->fresh()); + + $this->assertSame('pending', $submission->fresh()->identity_match_status); + } +} diff --git a/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormDraftLifecycleTest.php b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormDraftLifecycleTest.php new file mode 100644 index 00000000..9bbf1584 --- /dev/null +++ b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormDraftLifecycleTest.php @@ -0,0 +1,204 @@ +seed(RoleSeeder::class); + Config::set('form_builder.captcha.required_for_purposes', []); + + $org = Organisation::factory()->create(); + $this->schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'purpose' => FormPurpose::PUBLIC_RSVP, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + 'version' => 1, + ]); + FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'naam', + 'label' => 'Naam', + 'is_required' => true, + 'is_portal_visible' => true, + ]); + FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'field_type' => FormFieldType::EMAIL->value, + 'slug' => 'email', + 'label' => 'E-mail', + 'is_required' => true, + 'is_portal_visible' => true, + ]); + } + + public function test_post_creates_draft_with_status_201(): void + { + $response = $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions", + ['idempotency_key' => 'first-key-ulid-here'], + ); + + $response->assertCreated(); + $this->assertSame('draft', $response->json('data.status')); + } + + public function test_idempotent_replay_returns_existing_draft_as_200(): void + { + $key = 'idempotency-key-abc123'; + $first = $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions", + ['idempotency_key' => $key], + ); + $firstId = $first->json('data.id'); + + $second = $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions", + ['idempotency_key' => $key], + ); + $second->assertStatus(200); + $this->assertSame($firstId, $second->json('data.id')); + } + + public function test_idempotency_key_required(): void + { + $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions", + [], + )->assertStatus(422); + } + + public function test_put_partial_update(): void + { + $submission = $this->startDraft(); + + $this->putJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$submission->id}", + ['values' => ['naam' => 'Bart']], + )->assertOk(); + + $this->putJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$submission->id}", + ['values' => ['email' => 'bart@example.nl']], + )->assertOk(); + + $fresh = $submission->fresh(); + $valueMap = $fresh->values()->with('field')->get() + ->mapWithKeys(fn ($v) => [$v->field->slug => $v->value]) + ->all(); + + $this->assertSame('Bart', $valueMap['naam']); + $this->assertSame('bart@example.nl', $valueMap['email']); + $this->assertGreaterThanOrEqual(2, (int) $fresh->auto_save_count); + } + + public function test_submit_happy_path(): void + { + $submission = $this->startDraft(); + + $this->putJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$submission->id}", + ['values' => ['naam' => 'Bart', 'email' => 'bart@example.nl']], + )->assertOk(); + + $response = $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$submission->id}/submit", + [], + ); + + $response->assertCreated(); + $this->assertSame('submitted', $response->json('data.status')); + $this->assertSame(1, $response->json('data.schema_version_at_submit')); + } + + public function test_409_when_submitting_already_submitted(): void + { + $submission = $this->startDraft(); + $this->putJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$submission->id}", + ['values' => ['naam' => 'Bart', 'email' => 'bart@example.nl']], + ); + $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$submission->id}/submit", + [], + )->assertCreated(); + + $retry = $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$submission->id}/submit", + [], + ); + $retry->assertStatus(409); + $this->assertSame('SUBMISSION_ALREADY_SUBMITTED', $retry->json('code')); + } + + public function test_schema_drift_flagged_when_version_advances_after_draft(): void + { + $submission = $this->startDraft(); + $this->putJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$submission->id}", + ['values' => ['naam' => 'Bart', 'email' => 'bart@example.nl']], + ); + + // Simulate organiser editing the schema after draft creation. + $this->schema->forceFill(['version' => 2])->save(); + + $response = $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$submission->id}/submit", + [], + ); + $response->assertCreated(); + $this->assertTrue($response->json('data.schema_drift')); + } + + public function test_submission_from_different_schema_rejected(): void + { + $other = FormSchema::factory()->create([ + 'organisation_id' => $this->schema->organisation_id, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + ]); + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $other->id, + 'status' => 'draft', + ]); + + $response = $this->putJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$submission->id}", + ['values' => ['naam' => 'x']], + ); + $response->assertStatus(404); + } + + private function startDraft(): FormSubmission + { + $response = $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions", + ['idempotency_key' => 'draft-'.substr((string) Str::ulid(), 0, 20)], + ); + $response->assertCreated(); + + return FormSubmission::query()->findOrFail($response->json('data.id')); + } +} diff --git a/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormErrorEnvelopeTest.php b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormErrorEnvelopeTest.php new file mode 100644 index 00000000..28da6b61 --- /dev/null +++ b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormErrorEnvelopeTest.php @@ -0,0 +1,118 @@ +getJson('/api/v1/public/forms/'.Str::ulid()); + $response->assertStatus(404); + $this->assertSame('SCHEMA_NOT_FOUND', $response->json('code')); + $this->assertArrayHasKey('message', $response->json()); + } + + public function test_410_token_expired_envelope(): void + { + $this->seed(RoleSeeder::class); + $org = Organisation::factory()->create(); + $prev = (string) Str::ulid(); + FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + 'public_token_previous' => $prev, + 'public_token_rotated_at' => now()->subDays(10), + ]); + + $response = $this->getJson("/api/v1/public/forms/{$prev}"); + $response->assertStatus(410); + $this->assertSame('TOKEN_EXPIRED', $response->json('code')); + } + + public function test_410_schema_unpublished_envelope(): void + { + $this->seed(RoleSeeder::class); + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'is_published' => false, + 'public_token' => (string) Str::ulid(), + ]); + + $response = $this->postJson( + "/api/v1/public/forms/{$schema->public_token}/submissions", + ['idempotency_key' => 'unpublished-test-key'], + ); + + $response->assertStatus(410); + $this->assertSame('SCHEMA_UNPUBLISHED', $response->json('code')); + } + + public function test_422_validation_envelope_has_errors_key(): void + { + $this->seed(RoleSeeder::class); + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + ]); + + $response = $this->postJson( + "/api/v1/public/forms/{$schema->public_token}/submissions", + [], + ); + + $response->assertStatus(422); + $this->assertSame('VALIDATION_FAILED', $response->json('code')); + $this->assertIsArray($response->json('errors')); + } + + public function test_429_rate_limited_envelope_with_retry_after_header(): void + { + $this->seed(RoleSeeder::class); + Config::set('form_builder.captcha.required_for_purposes', []); + Config::set('form_builder.limits.max_submissions_per_public_schema_per_ip_per_hour', 1); + + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + ]); + + $key = 'form-submit:'.$schema->public_token.':127.0.0.1'; + // Burn the single allowed hit so the next submit exceeds it. + RateLimiter::hit($key, 3600); + RateLimiter::hit($key, 3600); + + $draft = $this->postJson( + "/api/v1/public/forms/{$schema->public_token}/submissions", + ['idempotency_key' => 'rate-limit-test-001'], + )->assertCreated()->json('data.id'); + + $response = $this->postJson( + "/api/v1/public/forms/{$schema->public_token}/submissions/{$draft}/submit", + [], + ); + + $response->assertStatus(429); + $this->assertSame('RATE_LIMITED', $response->json('code')); + $this->assertTrue($response->headers->has('Retry-After')); + } +} diff --git a/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSchemaResourceTest.php b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSchemaResourceTest.php new file mode 100644 index 00000000..e001f016 --- /dev/null +++ b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSchemaResourceTest.php @@ -0,0 +1,97 @@ +seed(RoleSeeder::class); + $org = Organisation::factory()->create(); + $org->personTags()->create(['name' => 'EHBO', 'category' => 'Veiligheid', 'is_active' => true, 'sort_order' => 1]); + $org->personTags()->create(['name' => 'Tapper', 'category' => 'Horeca', 'is_active' => true, 'sort_order' => 2]); + $org->personTags()->create(['name' => 'Dormant', 'category' => 'Horeca', 'is_active' => false, 'sort_order' => 3]); + + $schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + ]); + FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TAG_PICKER->value, + 'slug' => 'vaardigheden', + 'label' => 'Vaardigheden', + 'is_portal_visible' => true, + ]); + + $response = $this->getJson("/api/v1/public/forms/{$schema->public_token}"); + $response->assertOk(); + + $field = collect($response->json('data.fields'))->firstWhere('slug', 'vaardigheden'); + $this->assertNotNull($field); + $this->assertIsArray($field['available_tags']); + $this->assertCount(2, $field['available_tags']); + $this->assertSame(['EHBO', 'Tapper'], collect($field['available_tags'])->pluck('name')->sort()->values()->all()); + } + + public function test_tag_picker_filter_by_validation_rules_categories(): void + { + $this->seed(RoleSeeder::class); + $org = Organisation::factory()->create(); + $org->personTags()->create(['name' => 'EHBO', 'category' => 'Veiligheid', 'is_active' => true, 'sort_order' => 1]); + $org->personTags()->create(['name' => 'Tapper', 'category' => 'Horeca', 'is_active' => true, 'sort_order' => 2]); + + $schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + ]); + FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TAG_PICKER->value, + 'slug' => 'veiligheid', + 'validation_rules' => ['tag_categories' => ['Veiligheid']], + 'is_portal_visible' => true, + ]); + + $response = $this->getJson("/api/v1/public/forms/{$schema->public_token}"); + $field = collect($response->json('data.fields'))->firstWhere('slug', 'veiligheid'); + + $this->assertCount(1, $field['available_tags']); + $this->assertSame('EHBO', $field['available_tags'][0]['name']); + } + + public function test_version_and_opened_at_top_level(): void + { + $this->seed(RoleSeeder::class); + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + 'version' => 3, + ]); + + $response = $this->getJson("/api/v1/public/forms/{$schema->public_token}"); + $response->assertOk(); + $this->assertSame(3, $response->json('data.version')); + $this->assertNotNull($response->json('data.opened_at')); + } +} diff --git a/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSectionsTest.php b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSectionsTest.php new file mode 100644 index 00000000..5a872bd0 --- /dev/null +++ b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSectionsTest.php @@ -0,0 +1,91 @@ +seed(RoleSeeder::class); + $org = Organisation::factory()->create(); + $event = Event::factory()->create(['organisation_id' => $org->id]); + + FestivalSection::create([ + 'event_id' => $event->id, 'name' => 'Bar', 'type' => 'standard', + 'sort_order' => 1, 'show_in_registration' => true, 'registration_description' => 'Tappen', + ]); + FestivalSection::create([ + 'event_id' => $event->id, 'name' => 'Crew interne', 'type' => 'standard', + 'sort_order' => 2, 'show_in_registration' => false, + ]); + FestivalSection::create([ + 'event_id' => $event->id, 'name' => 'Cross-event', 'type' => 'cross_event', + 'sort_order' => 3, 'show_in_registration' => true, + ]); + + $schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + 'owner_type' => 'event', + 'owner_id' => $event->id, + ]); + + $response = $this->getJson("/api/v1/public/forms/{$schema->public_token}/sections"); + $response->assertOk(); + $names = collect($response->json('data'))->pluck('name')->all(); + $this->assertContains('Bar', $names); + $this->assertNotContains('Crew interne', $names); + $this->assertNotContains('Cross-event', $names); + } + + public function test_festival_children_dedup_by_name(): void + { + $this->seed(RoleSeeder::class); + $org = Organisation::factory()->create(); + $festival = Event::factory()->create(['organisation_id' => $org->id, 'event_type' => 'festival']); + $dayOne = Event::factory()->create(['organisation_id' => $org->id, 'parent_event_id' => $festival->id]); + $dayTwo = Event::factory()->create(['organisation_id' => $org->id, 'parent_event_id' => $festival->id]); + + foreach ([$dayOne, $dayTwo] as $day) { + FestivalSection::create([ + 'event_id' => $day->id, 'name' => 'Bar', 'type' => 'standard', + 'sort_order' => 1, 'show_in_registration' => true, + ]); + } + FestivalSection::create([ + 'event_id' => $dayOne->id, 'name' => 'Podium', 'type' => 'standard', + 'sort_order' => 2, 'show_in_registration' => true, + ]); + + $schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + 'owner_type' => 'event', + 'owner_id' => $festival->id, + ]); + + $response = $this->getJson("/api/v1/public/forms/{$schema->public_token}/sections"); + $names = collect($response->json('data'))->pluck('name')->all(); + $this->assertCount(2, $names, 'Bar should be deduplicated across days'); + $this->assertContains('Bar', $names); + $this->assertContains('Podium', $names); + } +} diff --git a/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSubmissionResourceTest.php b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSubmissionResourceTest.php new file mode 100644 index 00000000..2b833ca4 --- /dev/null +++ b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSubmissionResourceTest.php @@ -0,0 +1,120 @@ +seed(RoleSeeder::class); + Config::set('form_builder.captcha.required_for_purposes', []); + + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'purpose' => FormPurpose::PUBLIC_RSVP, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + ]); + + $response = $this->postJson( + "/api/v1/public/forms/{$schema->public_token}/submissions", + [ + 'idempotency_key' => 'pii-test-key-001-ulid', + 'public_submitter_name' => 'Bart Hausmans', + 'public_submitter_email' => 'bart@secret.test', + ], + ); + $response->assertCreated(); + + $data = $response->json('data'); + $this->assertArrayNotHasKey('public_submitter_name', $data); + $this->assertArrayNotHasKey('public_submitter_email', $data); + $this->assertArrayNotHasKey('public_submitter_ip', $data); + $this->assertArrayNotHasKey('submitted_by_user_id', $data); + } + + public function test_admin_metadata_hidden(): void + { + $this->seed(RoleSeeder::class); + Config::set('form_builder.captcha.required_for_purposes', []); + + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + ]); + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $schema->id, + 'status' => 'draft', + 'review_status' => 'pending_review', + 'reviewed_by_user_id' => null, + 'schema_snapshot' => ['leaky' => 'data'], + ]); + + $response = $this->putJson( + "/api/v1/public/forms/{$schema->public_token}/submissions/{$submission->id}", + ['values' => []], + ); + $response->assertOk(); + + $data = $response->json('data'); + $this->assertArrayNotHasKey('review_status', $data); + $this->assertArrayNotHasKey('reviewed_by_user_id', $data); + $this->assertArrayNotHasKey('schema_snapshot', $data); + $this->assertArrayNotHasKey('public_submitter_ip_anonymised_at', $data); + } + + public function test_identity_match_shape_present_when_status_set(): void + { + $this->seed(RoleSeeder::class); + Config::set('form_builder.captcha.required_for_purposes', []); + + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + ]); + FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'naam', + 'is_portal_visible' => true, + ]); + + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $schema->id, + 'status' => 'draft', + ]); + // Simulate the listener writing the column. + FormSubmission::query() + ->whereKey($submission->id) + ->update(['identity_match_status' => 'pending']); + + $response = $this->putJson( + "/api/v1/public/forms/{$schema->public_token}/submissions/{$submission->id}", + ['values' => []], + ); + $response->assertOk(); + $this->assertSame('pending', $response->json('data.identity_match.status')); + $this->assertStringContainsString('controleren', (string) $response->json('data.identity_match.message')); + } +} diff --git a/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormTimeSlotsTest.php b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormTimeSlotsTest.php new file mode 100644 index 00000000..8e2ee0aa --- /dev/null +++ b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormTimeSlotsTest.php @@ -0,0 +1,101 @@ +makeFlatEventSchema(); + + TimeSlot::create(['event_id' => $event->id, 'name' => 'Ochtend', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-10', 'start_time' => '09:00', 'end_time' => '13:00', 'duration_hours' => 4]); + TimeSlot::create(['event_id' => $event->id, 'name' => 'Crew-only', 'person_type' => 'CREW', 'date' => '2026-07-10', 'start_time' => '06:00', 'end_time' => '22:00', 'duration_hours' => 16]); + + $response = $this->getJson("/api/v1/public/forms/{$schema->public_token}/time-slots"); + $response->assertOk(); + + $names = collect($response->json('data'))->pluck('name')->all(); + $this->assertContains('Ochtend', $names); + $this->assertNotContains('Crew-only', $names); + } + + public function test_festival_children_included(): void + { + $this->seed(RoleSeeder::class); + $org = Organisation::factory()->create(); + $festival = Event::factory()->create(['organisation_id' => $org->id, 'event_type' => 'festival']); + $dayOne = Event::factory()->create(['organisation_id' => $org->id, 'parent_event_id' => $festival->id]); + $dayTwo = Event::factory()->create(['organisation_id' => $org->id, 'parent_event_id' => $festival->id]); + + TimeSlot::create(['event_id' => $dayOne->id, 'name' => 'Dag 1 ochtend', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-10', 'start_time' => '09:00', 'end_time' => '13:00', 'duration_hours' => 4]); + TimeSlot::create(['event_id' => $dayTwo->id, 'name' => 'Dag 2 middag', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-11', 'start_time' => '13:00', 'end_time' => '18:00', 'duration_hours' => 5]); + + $schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + 'owner_type' => 'event', + 'owner_id' => $festival->id, + ]); + + $response = $this->getJson("/api/v1/public/forms/{$schema->public_token}/time-slots"); + $response->assertOk(); + $this->assertCount(2, $response->json('data')); + } + + public function test_expired_token_returns_410_with_code(): void + { + [$schema] = $this->makeFlatEventSchema(); + $previous = (string) Str::ulid(); + $schema->forceFill([ + 'public_token_previous' => $previous, + 'public_token_rotated_at' => now()->subDays(10), + ])->save(); + + $response = $this->getJson("/api/v1/public/forms/{$previous}/time-slots"); + $response->assertStatus(410); + $this->assertSame('TOKEN_EXPIRED', $response->json('code')); + } + + public function test_unknown_token_returns_404_with_code(): void + { + $response = $this->getJson('/api/v1/public/forms/'.Str::ulid().'/time-slots'); + $response->assertStatus(404); + $this->assertSame('SCHEMA_NOT_FOUND', $response->json('code')); + } + + /** + * @return array{0: FormSchema, 1: Event} + */ + private function makeFlatEventSchema(): array + { + $this->seed(RoleSeeder::class); + $org = Organisation::factory()->create(); + $event = Event::factory()->create(['organisation_id' => $org->id]); + $schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + 'owner_type' => 'event', + 'owner_id' => $event->id, + ]); + + return [$schema, $event]; + } +} diff --git a/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormValidationTest.php b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormValidationTest.php new file mode 100644 index 00000000..a0505a06 --- /dev/null +++ b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormValidationTest.php @@ -0,0 +1,148 @@ +seed(RoleSeeder::class); + Config::set('form_builder.captcha.required_for_purposes', []); + + $org = Organisation::factory()->create(); + $this->schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'purpose' => FormPurpose::PUBLIC_RSVP, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + ]); + FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'field_type' => FormFieldType::EMAIL->value, + 'slug' => 'contact_email', + 'label' => 'E-mail', + 'is_required' => true, + 'is_portal_visible' => true, + ]); + FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'field_type' => FormFieldType::NUMBER->value, + 'slug' => 'leeftijd', + 'label' => 'Leeftijd', + 'is_required' => false, + 'is_portal_visible' => true, + 'validation_rules' => ['min' => 16, 'max' => 99], + ]); + FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'field_type' => FormFieldType::SELECT->value, + 'slug' => 'shirtmaat', + 'label' => 'Shirtmaat', + 'options' => ['S', 'M', 'L'], + 'is_required' => false, + 'is_portal_visible' => true, + ]); + } + + public function test_email_rejects_bad_format(): void + { + $draft = $this->startDraft(); + + $response = $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$draft->id}/submit", + ['values' => ['contact_email' => 'not-an-email']], + ); + + $response->assertStatus(422); + $this->assertSame('VALIDATION_FAILED', $response->json('code')); + $this->assertArrayHasKey('values.contact_email', $response->json('errors')); + } + + public function test_number_rejects_string(): void + { + $draft = $this->startDraft(); + + $response = $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$draft->id}/submit", + ['values' => ['contact_email' => 'x@y.nl', 'leeftijd' => 'twenty-five']], + ); + + $response->assertStatus(422); + $this->assertSame('VALIDATION_FAILED', $response->json('code')); + } + + public function test_select_rejects_unknown_option(): void + { + $draft = $this->startDraft(); + + $response = $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$draft->id}/submit", + ['values' => ['contact_email' => 'x@y.nl', 'shirtmaat' => 'XXXXL']], + ); + + $response->assertStatus(422); + } + + public function test_number_min_max_enforced(): void + { + $draft = $this->startDraft(); + + $response = $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$draft->id}/submit", + ['values' => ['contact_email' => 'x@y.nl', 'leeftijd' => 5]], + ); + + $response->assertStatus(422); + } + + public function test_required_field_missing_rejected_on_submit(): void + { + $draft = $this->startDraft(); + + $response = $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$draft->id}/submit", + ['values' => []], + ); + + $response->assertStatus(422); + } + + public function test_draft_save_tolerates_missing_required_fields(): void + { + $draft = $this->startDraft(); + + $this->putJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$draft->id}", + ['values' => ['leeftijd' => 20]], + )->assertOk(); + } + + private function startDraft(): FormSubmission + { + $response = $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions", + ['idempotency_key' => 'validation-'.substr((string) Str::ulid(), 0, 18)], + ); + + return FormSubmission::query()->findOrFail($response->json('data.id')); + } +}