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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user