test(form-builder): public form API — 36 new tests covering S2c deliverables

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 23:03:28 +02:00
parent 71d2b4294d
commit 9b1bf0e13d
8 changed files with 1050 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Public\FormBuilder;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Events\FormBuilder\FormSubmissionSubmitted;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Organisation;
use App\Models\Person;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* ARCH §31.1 TriggerPersonIdentityMatchOnFormSubmit contract.
* Verifies: (a) only fires for event_registration, (b) matched/pending/
* none states written correctly, (c) listener failures never rethrow.
*/
final class IdentityMatchOnSubmitTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private Event $event;
private CrowdType $crowdType;
private FormSchema $schema;
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]);
$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);
}
}

View File

@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Public\FormBuilder;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Organisation;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
use Tests\TestCase;
final class PublicFormDraftLifecycleTest extends TestCase
{
use RefreshDatabase;
private FormSchema $schema;
protected function setUp(): void
{
parent::setUp();
$this->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'));
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Public\FormBuilder;
use App\Enums\FormBuilder\FormPurpose;
use App\Models\FormBuilder\FormSchema;
use App\Models\Organisation;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Tests\TestCase;
final class PublicFormErrorEnvelopeTest extends TestCase
{
use RefreshDatabase;
public function test_404_schema_not_found_envelope(): void
{
$response = $this->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'));
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Public\FormBuilder;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\Organisation;
use App\Models\PersonTag;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
final class PublicFormSchemaResourceTest extends TestCase
{
use RefreshDatabase;
public function test_available_tags_present_on_tag_picker_fields(): 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]);
$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'));
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Public\FormBuilder;
use App\Enums\FormBuilder\FormPurpose;
use App\Models\Event;
use App\Models\FestivalSection;
use App\Models\FormBuilder\FormSchema;
use App\Models\Organisation;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
final class PublicFormSectionsTest extends TestCase
{
use RefreshDatabase;
public function test_show_in_registration_filter_applied(): void
{
$this->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);
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Public\FormBuilder;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Organisation;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
use Tests\TestCase;
final class PublicFormSubmissionResourceTest extends TestCase
{
use RefreshDatabase;
public function test_no_pii_echo_in_response(): 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,
'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'));
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Public\FormBuilder;
use App\Enums\FormBuilder\FormPurpose;
use App\Models\Event;
use App\Models\FormBuilder\FormSchema;
use App\Models\Organisation;
use App\Models\TimeSlot;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
final class PublicFormTimeSlotsTest extends TestCase
{
use RefreshDatabase;
public function test_volunteer_filter_applied(): void
{
[$schema, $event] = $this->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];
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Public\FormBuilder;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Organisation;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
use Tests\TestCase;
final class PublicFormValidationTest extends TestCase
{
use RefreshDatabase;
private FormSchema $schema;
protected function setUp(): void
{
parent::setUp();
$this->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'));
}
}